Compare commits

...

11 Commits

Author SHA1 Message Date
陈大猫
17c8f11194 Fix macOS package ad-hoc signing (#1433)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-12 12:36:46 +08:00
陈大猫
4d1a7ea55a Prevent app reload shortcut from closing sessions (#1432) 2026-06-12 12:10:59 +08:00
陈大猫
babe06a944 Fix local debug launch
Fix local debug launch
2026-06-12 11:54:26 +08:00
陈大猫
9e31d53bdd Slim release package
Slim release package
2026-06-12 11:48:59 +08:00
lengyuqu
ea24841939 Fix Windows AI model selection
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Fix the Windows issue where AI models could not be selected.
2026-06-12 01:42:38 +08:00
陈大猫
bf9f557e42 Fix popup timestamps and sidebar return animation
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
2026-06-12 01:35:50 +08:00
陈大猫
106e748a9b Fix terminal tab theme sync (#1421) 2026-06-12 01:21:28 +08:00
陈大猫
94fff62f9b [codex] Virtualize process manager list
Render only visible process rows while preserving sorting, search, status pills, and row actions.
2026-06-12 00:57:05 +08:00
陈大猫
324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00
陈大猫
e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫
7b4f046001 [codex] Optimize AI settings agent performance (#1416)
* Optimize AI settings agent performance

* Address AI settings review feedback

* Retry interrupted agent discovery

* Harden agent CLI probe timeout cleanup

* Avoid duplicate agent discovery retries

* Ensure timed-out CLI probes are killed
2026-06-11 22:38:08 +08:00
91 changed files with 4030 additions and 1502 deletions

View File

@@ -28,6 +28,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
mergeTerminalHostUpdate,
type TerminalHostUpdate,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
@@ -874,7 +875,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.agents': 'Agents',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',

View File

@@ -44,6 +44,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.loading': 'Loading processes…',
'systemManager.processes.loadingMore': 'Loading more processes…',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
@@ -55,6 +57,10 @@ export const enSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.common.checkingAvailability': 'Checking availability…',
'systemManager.common.loading': 'Loading…',
'systemManager.common.loadingDetails': 'Loading details…',
'systemManager.common.loadingStats': 'Loading stats…',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',

View File

@@ -30,6 +30,8 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.timestampsEnable': 'Show timestamps',
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',

View File

@@ -530,8 +530,8 @@ export const enVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
'hostDetails.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.agents': 'Агенты',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',

View File

@@ -44,6 +44,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.loading': 'Загрузка процессов…',
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
@@ -55,6 +57,10 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.common.checkingAvailability': 'Проверка доступности…',
'systemManager.common.loading': 'Загрузка…',
'systemManager.common.loadingDetails': 'Загрузка деталей…',
'systemManager.common.loadingStats': 'Загрузка статистики…',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',

View File

@@ -51,6 +51,8 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.timestampsEnable': 'Показать время',
'terminal.toolbar.timestampsDisable': 'Скрыть время',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',

View File

@@ -562,8 +562,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
'hostDetails.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',

View File

@@ -44,6 +44,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.loading': '正在加载进程…',
'systemManager.processes.loadingMore': '正在显示更多进程…',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
@@ -55,6 +57,10 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.common.checkingAvailability': '正在检查可用状态…',
'systemManager.common.loading': '正在加载…',
'systemManager.common.loadingDetails': '正在加载详情…',
'systemManager.common.loadingStats': '正在加载性能数据…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',

View File

@@ -2,6 +2,8 @@ import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',

View File

@@ -113,8 +113,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.sshAlgorithms': 'SSH 算法',
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '输出时间',
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
'hostDetails.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',

View File

@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}

View File

@@ -74,3 +74,56 @@ test("runThemeTransition uses view transition API when available", async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(finished, true);
});
test("runThemeTransition handles skipped view transitions", async () => {
const root = createRoot();
let applied = false;
let rejectFinished!: (reason: unknown) => void;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: new Promise<void>((_, reject) => {
rejectFinished = reject;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition can apply without animation for heavy tab switches", () => {
const root = createRoot();
let applied = false;
let startViewTransitionCalled = false;
const doc = {
startViewTransition: (callback: () => void) => {
startViewTransitionCalled = true;
callback();
return {
finished: Promise.resolve(),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, { root, mode: "instant" });
assert.equal(applied, true);
assert.equal(startViewTransitionCalled, false);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});

View File

@@ -2,6 +2,7 @@ import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
export type ThemeTransitionMode = 'view' | 'css' | 'instant';
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => {
@@ -10,12 +11,57 @@ type DocumentWithViewTransition = Document & {
};
};
type ThemeTransitionOptions = {
root?: HTMLElement;
mode?: ThemeTransitionMode;
};
let cancelThemeTransitionReset: (() => void) | null = null;
function resolveOptions(rootOrOptions?: HTMLElement | ThemeTransitionOptions): Required<ThemeTransitionOptions> {
if (
rootOrOptions
&& (
Object.prototype.hasOwnProperty.call(rootOrOptions, 'root')
|| Object.prototype.hasOwnProperty.call(rootOrOptions, 'mode')
)
) {
const options = rootOrOptions as ThemeTransitionOptions;
return {
root: options.root ?? document.documentElement,
mode: options.mode ?? 'view',
};
}
return {
root: rootOrOptions as HTMLElement | undefined ?? document.documentElement,
mode: 'view',
};
}
function runCssThemeTransition(apply: () => void, root: HTMLElement, cleanup: () => void): void {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
}
function skipViewTransition(transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>>): void {
try {
transition.skipTransition();
} catch {
// Already finished or skipped by the browser.
}
}
export function runThemeTransition(
apply: () => void,
root: HTMLElement = document.documentElement,
rootOrOptions?: HTMLElement | ThemeTransitionOptions,
): void {
const { root, mode } = resolveOptions(rootOrOptions);
cancelThemeTransitionReset?.();
const cleanup = () => {
@@ -23,6 +69,17 @@ export function runThemeTransition(
cancelThemeTransitionReset = null;
};
if (mode === 'instant') {
apply();
cleanup();
return;
}
if (mode === 'css') {
runCssThemeTransition(apply, root, cleanup);
return;
}
const doc = root.ownerDocument as DocumentWithViewTransition | null;
const startViewTransition = doc?.startViewTransition?.bind(doc);
@@ -33,29 +90,19 @@ export function runThemeTransition(
apply();
});
} catch {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
runCssThemeTransition(apply, root, cleanup);
return;
}
cancelThemeTransitionReset = () => {
transition?.skipTransition();
if (transition) {
skipViewTransition(transition);
}
cleanup();
};
void transition.finished.finally(cleanup);
void transition.finished.then(cleanup, cleanup);
return;
}
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
runCssThemeTransition(apply, root, cleanup);
}

View File

@@ -0,0 +1,340 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIPermissionMode,
AIToolIntegrationMode,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
}
function readToolIntegrationMode(): AIToolIntegrationMode {
return localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
}
export function useAISettingsState() {
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
);
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
);
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
);
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(readPermissionMode);
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(readToolIntegrationMode);
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
);
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
}, []);
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders((prev) => [...prev, provider]);
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders((prev) => prev.map((provider) => (
provider.id === id ? { ...provider, ...updates } : provider
)));
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
setProviders((prev) => prev.filter((provider) => provider.id !== id));
setActiveProviderIdRaw((prevId) => {
if (prevId !== id) return prevId;
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, '');
return '';
});
const agentProviderMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const agentModelMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
const cleanup = removeProviderReferences(id, agentProviderMap, agentModelMap);
if (cleanup.providerMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
}
if (cleanup.modelMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
}
}, [setProviders]);
const setActiveProviderId = useCallback((id: string) => {
setActiveProviderIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
}, []);
const setActiveModelId = useCallback((id: string) => {
setActiveModelIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
}, []);
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
setGlobalPermissionModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
}, []);
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
return next;
});
}, []);
const setDefaultAgentId = useCallback((id: string) => {
setDefaultAgentIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
}, []);
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
setQuickMessagesRaw((prev) => {
const nextRaw = typeof value === 'function' ? value(prev) : value;
const next = sanitizeQuickMessages(nextRaw);
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
return next;
});
}, []);
useEffect(() => {
const syncFromStorageKey = (key: string | null) => {
try {
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) break;
setProvidersRaw(parsed ?? []);
break;
}
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
break;
case STORAGE_KEY_AI_ACTIVE_MODEL:
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
break;
case STORAGE_KEY_AI_PERMISSION_MODE:
setGlobalPermissionModeRaw(readPermissionMode());
getAIBridge()?.aiMcpSetPermissionMode?.(readPermissionMode());
break;
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
setToolIntegrationModeRaw(readToolIntegrationMode());
getAIBridge()?.aiMcpSetToolIntegrationMode?.(readToolIntegrationMode());
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) break;
setExternalAgentsRaw(agents ?? []);
break;
}
case STORAGE_KEY_AI_DEFAULT_AGENT:
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) break;
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
if (!Number.isFinite(timeout)) break;
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) break;
setMaxIterationsRaw(iters);
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
case STORAGE_KEY_AI_QUICK_MESSAGES:
setQuickMessagesRaw(sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)));
break;
}
} catch (err) {
console.warn('[useAISettingsState] Failed to process AI settings storage change', key, err);
}
};
const handleStorage = (event: StorageEvent) => syncFromStorageKey(event.key);
const handleLocalStateChanged = (event: Event) => {
syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
useEffect(() => {
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(commandBlocklist);
bridge?.aiMcpSetCommandTimeout?.(commandTimeout);
bridge?.aiMcpSetMaxIterations?.(maxIterations);
bridge?.aiMcpSetPermissionMode?.(globalPermissionMode);
bridge?.aiMcpSetToolIntegrationMode?.(toolIntegrationMode);
}, [commandBlocklist, commandTimeout, globalPermissionMode, maxIterations, toolIntegrationMode]);
const activeProvider = providers.find((provider) => provider.id === activeProviderId) ?? null;
return useMemo(() => ({
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
}), [
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
]);
}

View File

@@ -115,7 +115,9 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? []
);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
@@ -124,7 +126,9 @@ export function useAIState() {
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {}
);
// Per-scope draft/view state is intentionally memory-only so a relaunch
// does not restore stale composer input or panel intent against new history.
@@ -185,7 +189,7 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};

View File

@@ -3,7 +3,19 @@ import test from "node:test";
import {
scheduleChromeLayoutAnimation,
syncActiveChromeTheme,
themeFingerprint,
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
function createInlineStyle() {
const values = new Map<string, string>();
return {
getPropertyValue: (name: string) => values.get(name) ?? "",
setProperty: (name: string, value: string) => values.set(name, value),
removeProperty: (name: string) => values.delete(name),
};
}
function createRafRoot() {
const callbacks = new Map<number, FrameRequestCallback>();
@@ -47,3 +59,37 @@ test("chrome layout animations wait until theme settle frames complete", () => {
assert.equal(ran, true);
cancel();
});
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
const originalDocument = globalWithDocument.document;
const theme = TERMINAL_THEMES[0];
assert.ok(theme);
const topTabsRoot = {
style: createInlineStyle(),
};
const documentElement = {
dataset: { activeChromeTheme: themeFingerprint(theme) },
};
const fakeDocument = {
documentElement,
querySelector: (selector: string) => selector === "[data-top-tabs-root]" ? topTabsRoot : null,
};
globalWithDocument.document = fakeDocument as unknown as Document;
try {
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an unchanged active chrome theme");
});
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
} finally {
if (originalDocument) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
}
});

View File

@@ -208,10 +208,17 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
}
style.textContent = getChromeCss(theme);
root.dataset.activeChromeTheme = themeFingerprint(theme);
refreshActiveChromeThemeSurfaces(theme);
}, { mode: "instant" });
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
applyTopTabsChromeThemeVars(theme);
});
}
applyTopTabsChromeThemeVars(theme);
}
export function syncActiveChromeTheme(
@@ -220,7 +227,14 @@ export function syncActiveChromeTheme(
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) return;
if (nextFingerprint === appliedFingerprint) {
if (activeTheme) {
refreshActiveChromeThemeSurfaces(activeTheme);
} else {
clearTopTabsChromeThemeVars();
}
return;
}
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
@@ -231,7 +245,7 @@ export function syncActiveChromeTheme(
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
});
}, { mode: "instant" });
}
export function useActiveChromeTheme({

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
@@ -10,6 +10,15 @@ function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
const AGENT_DISCOVERY_CACHE_TTL_MS = 60_000;
let agentDiscoveryCache: {
agents: DiscoveredAgent[];
apiKeyPresent: boolean;
updatedAt: number;
} | null = null;
const agentDiscoveryPromises = new Map<string, Promise<DiscoveredAgent[]>>();
let agentDiscoveryWriteGeneration = 0;
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
@@ -18,29 +27,87 @@ export function useAgentDiscovery(
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discoverSeqRef = useRef(0);
const mountedRef = useRef(true);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => () => {
mountedRef.current = false;
discoverSeqRef.current += 1;
}, []);
const cursorApiKeyPresent = externalAgents.some(
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
);
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
if (!enabledRef.current) return;
const bridge = getBridge();
if (!bridge) return;
const forceRefresh = discoverOptions?.refreshShellEnv === true;
const cacheFresh =
agentDiscoveryCache
&& agentDiscoveryCache.apiKeyPresent === cursorApiKeyPresent
&& Date.now() - agentDiscoveryCache.updatedAt < AGENT_DISCOVERY_CACHE_TTL_MS;
if (!forceRefresh && cacheFresh) {
startTransition(() => setDiscoveredAgents(agentDiscoveryCache?.agents ?? []));
return;
}
setIsDiscovering(true);
const discoverSeq = ++discoverSeqRef.current;
const writeGeneration = ++agentDiscoveryWriteGeneration;
const promiseKey = JSON.stringify({
apiKeyPresent: cursorApiKeyPresent,
refreshShellEnv: forceRefresh,
});
try {
const agents = await bridge.aiDiscoverAgents({
...discoverOptions,
let discoveryPromise = agentDiscoveryPromises.get(promiseKey) ?? null;
if (!discoveryPromise) {
const sharedPromise = bridge.aiDiscoverAgents({
...discoverOptions,
apiKeyPresent: cursorApiKeyPresent,
}).finally(() => {
if (agentDiscoveryPromises.get(promiseKey) === sharedPromise) {
agentDiscoveryPromises.delete(promiseKey);
}
});
agentDiscoveryPromises.set(promiseKey, sharedPromise);
discoveryPromise = sharedPromise;
}
const agents = await discoveryPromise;
if (
!mountedRef.current
|| !enabledRef.current
|| discoverSeq !== discoverSeqRef.current
|| writeGeneration !== agentDiscoveryWriteGeneration
) return;
agentDiscoveryCache = {
agents,
apiKeyPresent: cursorApiKeyPresent,
});
setDiscoveredAgents(agents);
updatedAt: Date.now(),
};
startTransition(() => setDiscoveredAgents(agents));
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
if (mountedRef.current && discoverSeq === discoverSeqRef.current) {
setIsDiscovering(false);
}
}
}, [cursorApiKeyPresent]);
useEffect(() => {
discoverSeqRef.current += 1;
if (!enabled) {
setIsDiscovering(false);
}
}, [cursorApiKeyPresent, enabled]);
useEffect(() => {
if (!enabled) return;
@@ -68,6 +135,7 @@ export function useAgentDiscovery(
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
if (!enabled) return;
setExternalAgents((prev) => {
let changed = false;
@@ -102,7 +170,7 @@ export function useAgentDiscovery(
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
}, [discoveredAgents, enabled, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import type { AIDraft, AISession } from '../infrastructure/ai/types';
import {
aiChatSidePanelPropsAreEqual,
hasAIChatSidePanelRetainedContent,
shouldKeepAIChatSidePanelMounted,
} from './AIChatSidePanel.tsx';
@@ -100,3 +101,17 @@ test('hidden AI side panel is retained when it has session messages', () => {
test('visible AI side panel is always mounted even when empty', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
});
test('AI side panel re-renders when retained content becomes visible again', () => {
const hiddenProps = baseProps({
isVisible: false,
draftsByScope: {
'terminal:terminal-1': draft({ text: 'hello' }),
},
});
assert.equal(aiChatSidePanelPropsAreEqual(
hiddenProps,
{ ...hiddenProps, isVisible: true },
), false);
});

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import type {
@@ -19,6 +20,7 @@ import {
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
@@ -55,6 +57,77 @@ import {
profileAIPanelCalculation,
} from './ai/aiPanelDiagnostics';
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null;
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
let userSkillsStatusCache: {
version: number;
result: UserSkillsStatusResult;
updatedAt: number;
} | null = null;
let userSkillsStatusPromise: {
version: number;
promise: Promise<UserSkillsStatusLoadResult>;
} | null = null;
let userSkillsStatusCacheVersion = 0;
function invalidateUserSkillsStatusCache() {
userSkillsStatusCacheVersion += 1;
userSkillsStatusCache = null;
userSkillsStatusPromise = null;
}
if (typeof window !== 'undefined') {
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
}
function loadUserSkillsStatus(
bridge: ReturnType<typeof getNetcattyBridge>,
): Promise<UserSkillsStatusLoadResult> {
const requestVersion = userSkillsStatusCacheVersion;
if (!bridge?.aiUserSkillsGetStatus) {
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return Promise.resolve(null);
}
if (
userSkillsStatusCache
&& userSkillsStatusCache.version === requestVersion
&& Date.now() - userSkillsStatusCache.updatedAt < USER_SKILLS_STATUS_CACHE_TTL_MS
) {
return Promise.resolve(userSkillsStatusCache.result);
}
if (!userSkillsStatusPromise || userSkillsStatusPromise.version !== requestVersion) {
const promise = bridge.aiUserSkillsGetStatus()
.then((result) => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result, updatedAt: Date.now() };
return result;
})
.catch(() => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return null;
})
.finally(() => {
if (userSkillsStatusPromise?.version === requestVersion) {
userSkillsStatusPromise = null;
}
});
userSkillsStatusPromise = { version: requestVersion, promise };
}
return userSkillsStatusPromise.promise;
}
export function hasAIChatSidePanelRetainedContent(props: Pick<
AIChatSidePanelProps,
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
@@ -90,6 +163,49 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
return isAIChatSessionStreaming(sessionId);
}
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
if (!(props.isVisible ?? true)) return false;
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (isAIChatSessionStreaming(sessionId)) return false;
return !hasAIChatSidePanelRetainedContent(props);
}
function schedulePanelActivation(callback: () => void): () => void {
let timeoutId: number | null = null;
if (typeof requestAnimationFrame === 'function') {
const rafId = requestAnimationFrame(() => {
timeoutId = window.setTimeout(callback, 0);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
timeoutId = window.setTimeout(callback, 0);
return () => {
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing() {
const { t } = useI18n();
return (
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
<div className="h-8 w-36 rounded-md bg-muted/45" />
</div>
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
{t('ai.chat.preparing')}
</div>
</div>
</div>
);
});
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
@@ -141,6 +257,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const [userSkillsStatusVersion, setUserSkillsStatusVersion] = useState(0);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
@@ -367,25 +484,25 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
};
const bridge = getNetcattyBridge();
if (!bridge?.aiUserSkillsGetStatus) {
applyUserSkillsStatus(null);
return;
}
void bridge.aiUserSkillsGetStatus()
void loadUserSkillsStatus(bridge)
.then((result) => {
if (cancelled) return;
if (result === undefined) return;
applyUserSkillsStatus(result);
})
.catch(() => {
if (cancelled) return;
applyUserSkillsStatus(null);
});
.catch(() => {});
return () => {
cancelled = true;
};
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft, userSkillsStatusVersion]);
useEffect(() => {
const handleUserSkillsChanged = () => {
setUserSkillsStatusVersion((version) => version + 1);
};
return subscribeUserSkillsStatusChanged(handleUserSkillsChanged);
}, []);
useEffect(() => {
if (!isVisible) return;
@@ -1034,7 +1151,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'quickMessages',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
function aiChatSidePanelPropsAreEqual(
export function aiChatSidePanelPropsAreEqual(
prev: AIChatSidePanelProps,
next: AIChatSidePanelProps,
): boolean {
@@ -1050,6 +1167,7 @@ function aiChatSidePanelPropsAreEqual(
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
@@ -1061,7 +1179,25 @@ function aiChatSidePanelPropsAreEqual(
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
const shouldKeepMounted = shouldKeepAIChatSidePanelMounted(props);
const shouldDelayActivation = shouldKeepMounted && shouldDelayAIChatSidePanelActivation(props);
const activationKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const [activationReady, setActivationReady] = useState(!shouldDelayActivation);
useEffect(() => {
if (!shouldDelayActivation) {
setActivationReady(true);
return undefined;
}
setActivationReady(false);
return schedulePanelActivation(() => setActivationReady(true));
}, [activationKey, shouldDelayActivation]);
if (!shouldKeepMounted) return null;
if (shouldDelayActivation && !activationReady) {
return <AIChatSidePanelPreparing />;
}
// Keep hidden panels alive only when they contain real work (messages, draft
// content, or an active stream). Empty hidden panels can drop their heavy
// input/agent-picker subtree and remount cheaply when shown again.

View File

@@ -22,7 +22,9 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
// reduce to their basename rather than staying as the full path.
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}

View File

@@ -5,12 +5,12 @@
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAISettingsState } from "../application/state/useAISettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import { toast } from "./ui/toast";
@@ -126,7 +126,7 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
});
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
const aiState = useAISettingsState();
return (
<AITabErrorBoundary>

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Activity, Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -22,6 +22,7 @@ import {
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
type TerminalHostUpdate,
} from "../domain/terminalAppearance";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
@@ -93,6 +94,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
snippetPackages = [],
compactToolbar = false,
lineTimestampsAvailable = true,
chainHosts = [],
themePreviewId,
knownHosts = [],
@@ -187,6 +189,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
onUpdateHost?.(hostUpdate as Host);
}, [onUpdateHost]);
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
@@ -500,7 +505,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
pendingAuthRef,
termRef,
onUpdateHost,
onUpdateHost: handleUpdateHostFromTerminal,
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
@@ -1150,7 +1155,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
onUpdateHost={handleUpdateHostFromTerminal}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
@@ -1178,7 +1183,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenHistory,
onOpenTheme,
onToggleComposeBar,
onUpdateHost,
handleUpdateHostFromTerminal,
sessionId,
snippetPackages,
snippets,
@@ -1205,7 +1210,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -3,6 +3,7 @@ import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef,
import { activeTabStore } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
import {
getSessionActivityIdsToClear,
getValidSessionActivityIds,
@@ -161,9 +162,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const runPrewarm = () => prewarmAIStateStorageSnapshots();
if (typeof window.requestIdleCallback === 'function') {
const idleId = window.requestIdleCallback(runPrewarm, { timeout: 2500 });
return () => window.cancelIdleCallback(idleId);
}
const timeoutId = window.setTimeout(runPrewarm, 500);
return () => window.clearTimeout(timeoutId);
}, []);
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
if (cwd && cwd.trim().length > 0) {
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}

View File

@@ -306,6 +306,7 @@ function TerminalPopupPageInner() {
snippets={snippets}
snippetPackages={snippetPackages}
compactToolbar
lineTimestampsAvailable={false}
knownHosts={knownHosts}
isVisible
isFocused

View File

@@ -0,0 +1,38 @@
export const USER_SKILLS_STATUS_CHANGED_EVENT = 'netcatty:user-skills-status-changed';
const USER_SKILLS_STATUS_CHANGED_KEY = 'ai:user-skills-status-changed';
type SettingsBridge = {
notifySettingsChanged?: (payload: { key: string; value: unknown }) => void;
onSettingsChanged?: (callback: (payload: { key: string; value: unknown }) => void) => () => void;
};
function getSettingsBridge(): SettingsBridge | undefined {
return (window as unknown as { netcatty?: SettingsBridge }).netcatty;
}
export function notifyUserSkillsStatusChanged() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(USER_SKILLS_STATUS_CHANGED_EVENT));
getSettingsBridge()?.notifySettingsChanged?.({
key: USER_SKILLS_STATUS_CHANGED_KEY,
value: Date.now(),
});
}
export function subscribeUserSkillsStatusChanged(callback: () => void): () => void {
if (typeof window === 'undefined') return () => {};
const handleLocalEvent = () => callback();
window.addEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
const unsubscribeSettings = getSettingsBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === USER_SKILLS_STATUS_CHANGED_KEY) {
callback();
}
});
return () => {
window.removeEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
unsubscribeSettings?.();
};
}

View File

@@ -22,8 +22,10 @@ import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { Button } from "../../ui/button";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
import { notifyUserSkillsStatusChanged } from "../../ai/userSkillsStatusEvents";
import type {
AgentPathInfo,
@@ -56,6 +58,57 @@ import {
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
type IdleWindow = Window & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
function scheduleAfterFirstPaint(callback: () => void, delayMs = 0): () => void {
let cancelled = false;
let idleHandle: number | null = null;
const timeoutHandle = window.setTimeout(() => {
if (cancelled) return;
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
idleHandle = idleWindow.requestIdleCallback(() => {
if (!cancelled) callback();
}, { timeout: 1200 });
return;
}
callback();
}, delayMs);
return () => {
cancelled = true;
window.clearTimeout(timeoutHandle);
if (idleHandle !== null) {
(window as IdleWindow).cancelIdleCallback?.(idleHandle);
}
};
}
type AISettingsSubTab = "providers" | "agents" | "tools" | "search" | "safety";
function getSavedManagedAgentPathInfo(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): AgentPathInfo | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
const command = typeof managed?.command === "string" ? managed.command.trim() : "";
if (!managed || !command) return null;
const savedAvailable = managed.available === true || managed.enabled === true;
return {
path: command,
binPath: command,
version: null,
available: savedAvailable,
installed: true,
authenticated: undefined,
authSource: null,
};
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
@@ -127,14 +180,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
const [isCodexLoading, setIsCodexLoading] = useState(false);
const [codexError, setCodexError] = useState<string | null>(null);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
cursor: string;
codebuddy: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
// Path detection state
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
const [codexCustomPath, setCodexCustomPath] = useState("");
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "codex"),
);
const [codexCustomPath, setCodexCustomPath] = useState(() => initialManagedPathsRef.current?.codex ?? "");
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<AISettingsSubTab>("providers");
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "claude"),
);
const [claudeCustomPath, setClaudeCustomPath] = useState(() => initialManagedPathsRef.current?.claude ?? "");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const claudeManagedEnv = useMemo(
@@ -160,26 +228,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
[setExternalAgents],
);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
cursor: string;
codebuddy: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "copilot"),
);
const [copilotCustomPath, setCopilotCustomPath] = useState(() => initialManagedPathsRef.current?.copilot ?? "");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(null);
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "cursor"),
);
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(null);
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState("");
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "codebuddy"),
);
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState(() => initialManagedPathsRef.current?.codebuddy ?? "");
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
const codebuddyManagedEnv = useMemo(
@@ -209,15 +272,22 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const autoResolvedAgentStateRef = useRef<Partial<Record<ManagedAgentKey, "pending" | "done">>>({});
const codexIntegrationLoadedRef = useRef(false);
const userSkillsLoadedRef = useRef(false);
const mountedRef = useRef(true);
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
const codexRequestIdRef = useRef(0);
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
options?: { apiKeyPresent?: boolean },
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return null;
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
}, []);
const applyResolvedAgentPath = useCallback((agentKey: ManagedAgentKey, result: AgentPathInfo | null) => {
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
@@ -227,6 +297,31 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: agentKey === "cursor"
? setCursorPathInfo
: setCodebuddyPathInfo;
setInfo(result);
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
}, [setDefaultAgentId, setExternalAgents]);
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
options?: { apiKeyPresent?: boolean; refreshShellEnv?: boolean },
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return null;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
@@ -238,49 +333,66 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: setIsResolvingCodebuddy;
setResolving(true);
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
agentPathRequestIdRef.current[agentKey] = requestId;
const isCurrentRequest = () => (
mountedRef.current
&& agentPathRequestIdRef.current[agentKey] === requestId
);
try {
const result = await bridge.aiResolveCli({
command: agentKey,
customPath: customPath.trim(),
refreshShellEnv: agentKey === "cursor",
refreshShellEnv: Boolean(options?.refreshShellEnv),
...(agentKey === "cursor" ? { apiKeyPresent: Boolean(options?.apiKeyPresent ?? cursorApiKeyEncrypted) } : {}),
});
setInfo(result);
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
if (!isCurrentRequest()) return null;
applyResolvedAgentPath(agentKey, result);
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
if (isCurrentRequest()) {
setResolving(false);
}
}
}, [cursorApiKeyEncrypted, setExternalAgents, setDefaultAgentId]);
}, [applyResolvedAgentPath, cursorApiKeyEncrypted]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
void resolveAgentPath("cursor", initialManagedPathsRef.current?.cursor ?? "", { apiKeyPresent: Boolean(cursorApiKeyEncrypted) });
void resolveAgentPath("codebuddy", initialManagedPathsRef.current?.codebuddy ?? "");
}, [cursorApiKeyEncrypted, resolveAgentPath]);
if (activeSubTab !== "agents") return;
const initialPaths = initialManagedPathsRef.current;
const tasks: Array<{
key: ManagedAgentKey;
delayMs: number;
path: string;
options?: { apiKeyPresent?: boolean };
}> = [
{ key: "codex", delayMs: 160, path: initialPaths?.codex ?? "" },
{ key: "claude", delayMs: 440, path: initialPaths?.claude ?? "" },
{ key: "copilot", delayMs: 720, path: initialPaths?.copilot ?? "" },
{
key: "cursor",
delayMs: 1000,
path: initialPaths?.cursor ?? "",
options: { apiKeyPresent: Boolean(cursorApiKeyEncrypted) },
},
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
];
const cancelTasks = tasks
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
.map((task) => scheduleAfterFirstPaint(() => {
autoResolvedAgentStateRef.current[task.key] = "pending";
void resolveAgentPath(task.key, task.path, task.options).finally(() => {
autoResolvedAgentStateRef.current[task.key] = "done";
});
}, task.delayMs));
return () => {
for (const cancel of cancelTasks) cancel();
};
}, [activeSubTab, cursorApiKeyEncrypted, resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
@@ -293,7 +405,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
: agentKey === "codebuddy"
? codebuddyCustomPath
: "";
await resolveAgentPath(agentKey, customPath);
await resolveAgentPath(agentKey, customPath, { refreshShellEnv: true });
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, resolveAgentPath]);
const handleSaveCursorApiKey = useCallback(async (apiKey: string) => {
@@ -373,25 +485,46 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [agentOptions, defaultAgentId, setDefaultAgentId]);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
useEffect(() => {
const bridge = getBridge();
if (!bridge?.aiPrewarmShellEnv) return;
return scheduleAfterFirstPaint(() => {
void bridge.aiPrewarmShellEnv?.();
}, 900);
}, []);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration(opts);
if (!isCurrentRequest()) return;
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, []);
useEffect(() => {
void refreshCodexIntegration();
}, [refreshCodexIntegration]);
if (activeSubTab !== "agents") return;
if (codexIntegrationLoadedRef.current) return;
return scheduleAfterFirstPaint(() => {
codexIntegrationLoadedRef.current = true;
void refreshCodexIntegration();
}, 620);
}, [activeSubTab, refreshCodexIntegration]);
useEffect(() => {
if (!codexLoginSession || codexLoginSession.state !== "running") {
@@ -411,7 +544,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexLoginSession(result.session);
if (result.session.state !== "running") {
if (result.session.state === "success") {
void refreshCodexIntegration();
void refreshCodexIntegration({ validateChatGptAuth: true });
}
}
}).catch((err) => {
@@ -431,18 +564,26 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const bridge = getBridge();
if (!bridge?.aiCodexStartLogin) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin();
if (!isCurrentRequest()) return;
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
}
setCodexLoginSession(result.session);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, []);
@@ -474,19 +615,27 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const bridge = getBridge();
if (!bridge?.aiCodexLogout) return;
const requestId = codexRequestIdRef.current + 1;
codexRequestIdRef.current = requestId;
const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout();
if (!isCurrentRequest()) return;
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
}
setCodexLoginSession(null);
await refreshCodexIntegration();
await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true });
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
if (isCurrentRequest()) {
setCodexError(normalizeCodexBridgeError(err));
}
} finally {
setIsCodexLoading(false);
if (isCurrentRequest()) {
setIsCodexLoading(false);
}
}
}, [refreshCodexIntegration]);
@@ -504,6 +653,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
notifyUserSkillsStatusChanged();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
@@ -513,14 +663,13 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
if (activeSubTab !== "tools") return;
if (userSkillsLoadedRef.current) return;
return scheduleAfterFirstPaint(() => {
userSkillsLoadedRef.current = true;
void refreshUserSkillsStatus();
}, 520);
}, [activeSubTab, refreshUserSkillsStatus]);
const reservedUserSkillSlugs = useMemo(
() => (userSkillsStatus?.ok && userSkillsStatus.skills
@@ -539,6 +688,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
notifyUserSkillsStatusChanged();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
@@ -549,6 +699,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
return (
<SettingsTabContent value="ai">
<Tabs value={activeSubTab} onValueChange={(value) => setActiveSubTab(value as AISettingsSubTab)} className="space-y-5">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="providers">{t('ai.providers')}</TabsTrigger>
<TabsTrigger value="agents">{t('ai.agents')}</TabsTrigger>
<TabsTrigger value="tools">{t('ai.toolAccess.title')}</TabsTrigger>
<TabsTrigger value="search">{t("ai.webSearch.title")}</TabsTrigger>
<TabsTrigger value="safety">{t('ai.safety.title')}</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="m-0 space-y-6">
<SettingsSection
title={t('ai.providers')}
actions={<AddProviderDropdown onAdd={handleAddProvider} />}
@@ -569,7 +729,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
isActive={provider.id === activeProviderId}
onToggleEnabled={(enabled) => {
if (enabled) {
// Activate this provider, deactivate all others
setActiveProviderId(provider.id);
if (provider.defaultModel) {
setActiveModelId(provider.defaultModel);
@@ -577,12 +736,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
for (const p of providers) {
if (p.id === provider.id) {
if (!p.enabled) updateProvider(p.id, { enabled: true });
} else {
if (p.enabled) updateProvider(p.id, { enabled: false });
} else if (p.enabled) {
updateProvider(p.id, { enabled: false });
}
}
} else {
// Deactivate this provider
if (activeProviderId === provider.id) {
setActiveProviderId("");
setActiveModelId("");
@@ -598,7 +756,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
onRemove={() => handleRemoveProvider(provider.id)}
onUpdate={(updates) => {
updateProvider(provider.id, updates);
// If this is the active provider and model changed, update activeModelId
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
setActiveModelId(updates.defaultModel || "");
}
@@ -610,7 +767,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
)}
</SettingsSection>
</TabsContent>
<TabsContent value="agents" className="m-0 space-y-6">
<SettingsSection
title={t('ai.codex')}
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
@@ -625,7 +784,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
loginSession={codexLoginSession}
isLoading={isCodexLoading}
error={codexError}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -709,7 +868,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
)}
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection title={t('ai.toolAccess.title')}>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>
@@ -778,10 +939,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="border-t border-border/60 divide-y divide-border/60">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="py-3"
>
<div key={skill.id} className="py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{skill.name}</div>
@@ -828,13 +986,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setQuickMessages={setQuickMessages}
reservedUserSkillSlugs={reservedUserSkillSlugs}
/>
</TabsContent>
<TabsContent value="search" className="m-0 space-y-6">
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
</TabsContent>
{/* -- Safety Section -- */}
<TabsContent value="safety" className="m-0 space-y-6">
<SafetySettings
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
@@ -845,6 +1006,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
maxIterations={maxIterations}
setMaxIterations={setMaxIterations}
/>
</TabsContent>
</Tabs>
</SettingsTabContent>
);
};

View File

@@ -103,7 +103,9 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;

View File

@@ -1,7 +1,6 @@
import { Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useState } from 'react';
import { Loader2, Pause, Pencil, Play, Trash2, Zap } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo } from '../../domain/systemManager/types';
import { getContainerFlags } from '../../domain/systemManager/containerState';
import { DockerInspectView } from './DockerInspectView';
@@ -9,18 +8,17 @@ import { ResourceBar } from './ResourceBar';
import {
SystemPanelActionChip,
SystemPanelDetailStrip,
SystemPanelInlineError,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { usePolling } from './hooks/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface DockerContainerDetailProps {
container: DockerContainerInfo;
sessionId: string;
backend: Backend;
statsRefreshIntervalSec: number;
inspect: Record<string, unknown> | null;
inspectError?: string | null;
inspectLoading?: boolean;
stat?: DockerStatInfo | null;
statsLoading?: boolean;
pendingAction: DockerContainerAction | null;
onCloseInspect: () => void;
onRunAction: (containerId: string, action: DockerContainerAction, newName?: string) => Promise<void>;
@@ -28,10 +26,11 @@ interface DockerContainerDetailProps {
export const DockerContainerDetail = memo(function DockerContainerDetail({
container,
sessionId,
backend,
statsRefreshIntervalSec,
inspect,
inspectError = null,
inspectLoading = false,
stat = null,
statsLoading = false,
pendingAction,
onCloseInspect,
onRunAction,
@@ -40,20 +39,6 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
const shortId = container.id.slice(0, 12);
const { isRunning, isPaused } = getContainerFlags(container);
const statsFetcher = useCallback(async () => {
const result = await backend.getDockerStats({ sessionId, ids: [container.id] });
if (!result.success || !result.stats) {
throw new Error(result.error || t('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, container.id, sessionId, t]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
// docker stats still reports paused containers, so keep polling them.
const { data: stats } = usePolling<DockerStatInfo[]>(statsFetcher, statsIntervalMs, isRunning || isPaused);
const stat = stats?.find((s) => s.id === container.id || s.id.startsWith(shortId)) ?? stats?.[0];
const [renameOpen, setRenameOpen] = useState(false);
const actionBusy = pendingAction !== null;
@@ -61,7 +46,7 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
<>
<SystemPanelDetailStrip>
{container.ports && (
<div className="text-[10px] text-muted-foreground mb-2 truncate">{container.ports}</div>
<div className="text-[10px] text-muted-foreground mb-2 break-all">{container.ports}</div>
)}
{stat && (
<div className="space-y-1 mb-2">
@@ -70,6 +55,12 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
<div className="text-[10px] text-muted-foreground">{stat.netIO} · {stat.memUsage}</div>
</div>
)}
{!stat && statsLoading && (isRunning || isPaused) && (
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingStats')}
</div>
)}
<div className="flex flex-wrap items-center gap-0.5">
<SystemPanelActionChip title={t('systemManager.docker.renamePrompt')} disabled={actionBusy} onClick={() => setRenameOpen(true)}>
<Pencil size={11} /> {t('common.rename')}
@@ -94,6 +85,15 @@ export const DockerContainerDetail = memo(function DockerContainerDetail({
</SystemPanelActionChip>
</div>
</SystemPanelDetailStrip>
{inspectLoading && !inspect && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectError && !inspect && (
<SystemPanelInlineError message={inspectError} />
)}
{inspect && (
<DockerInspectView
kind="container"

View File

@@ -1,10 +1,10 @@
import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
@@ -16,6 +16,7 @@ import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
@@ -25,6 +26,7 @@ import {
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
@@ -53,6 +55,7 @@ interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
@@ -150,6 +153,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
@@ -159,10 +163,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<ContainerFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
// Invalidates in-flight inspect fetches when the selection changes —
// a slow response for container A must not render under container B.
const inspectSeqRef = useRef(0);
// Spinner feedback while a container action (stop/restart/…) runs;
// cleared only after the follow-up list refresh lands.
const [pendingAction, setPendingAction] = useState<{ id: string; action: DockerContainerAction } | null>(null);
@@ -179,13 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
containersFetcher,
listIntervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const matched = useMemo(() => {
const matched = useMemo<DockerContainerInfo[]>(() => {
const q = query.trim().toLowerCase();
return (containers ?? []).filter((container) => {
const containerList = containers ?? [];
return containerList.filter((container) => {
const { isRunning, isPaused } = getContainerFlags(container);
if (filter === 'running' && !isRunning) return false;
if (filter === 'stopped' && (isRunning || isPaused)) return false;
@@ -202,7 +204,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
(a: DockerContainerInfo, b: DockerContainerInfo) => a.name.localeCompare(b.name),
[],
);
const displayList = useStableListOrder(
const displayList = useStableListOrder<DockerContainerInfo, string>(
matched,
(c) => c.id,
`${filter}|${query}`,
@@ -214,6 +216,69 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
[displayList, selectedId],
);
const statContainerIds = useMemo(
() => {
if (!selectedContainer) return [];
const { isRunning, isPaused } = getContainerFlags(selectedContainer);
return isRunning || isPaused ? [selectedContainer.id] : [];
},
[selectedContainer],
);
const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
if (!result.success || !result.stats) {
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
statsFetcher,
statsIntervalMs,
isVisible && statContainerIds.length > 0,
undefined,
{ poll: isVisible, resetKey: `${sessionId}:${statContainerIds.join(',')}` },
);
const statsByContainerId = useMemo(() => {
const map = new Map<string, DockerStatInfo>();
for (const stat of stats ?? []) {
map.set(stat.id, stat);
map.set(stat.id.slice(0, 12), stat);
}
return map;
}, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${container.id}`
), [sessionId]);
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByContainerId,
loadRecord: loadContainerInspect,
refreshRecord: refreshContainerInspect,
invalidateMatching: invalidateContainerInspectMatching,
} = useAsyncRecordCache<DockerContainerInfo, Record<string, unknown>>({
items: containers ?? [],
enabled: isVisible && (containers?.length ?? 0) > 0,
getKey: getContainerInspectKey,
fetchRecord: fetchContainerInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const runAction = useCallback(async (
containerId: string,
action: DockerContainerAction,
@@ -234,34 +299,42 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
const affectedContainer = (containers ?? []).find((container) => (
container.id === containerId || container.id.startsWith(containerId)
));
invalidateContainerInspectMatching((key) => (
key === `${sessionId}:${containerId}` || key.startsWith(`${sessionId}:${containerId}`)
));
if (action === 'rm') {
setSelectedId(null);
setInspect(null);
inspectSeqRef.current += 1;
}
await refresh();
if (affectedContainer && action !== 'rm') {
void refreshContainerInspect(affectedContainer);
}
} finally {
setPendingAction(null);
}
}, [backend, refresh, sessionId, t]);
}, [
backend,
containers,
invalidateContainerInspectMatching,
refresh,
refreshContainerInspect,
sessionId,
t,
]);
const handleRowAction = useCallback((container: DockerContainerInfo, action: DockerContainerAction) => {
void runAction(container.id.slice(0, 12), action);
}, [runAction]);
const selectContainer = useCallback(async (container: DockerContainerInfo) => {
const selectContainer = useCallback((container: DockerContainerInfo) => {
const next = selectedId === container.id ? null : container.id;
setSelectedId(next);
setInspect(null);
const seq = ++inspectSeqRef.current;
if (!next) return;
const result = await backend.dockerInspect({
sessionId,
containerId: container.id.slice(0, 12),
});
if (inspectSeqRef.current !== seq) return;
setInspect(result.success ? (result.inspect ?? null) : null);
}, [backend, selectedId, sessionId]);
void loadContainerInspect(container, { force: true, urgent: true });
}, [loadContainerInspect, selectedId]);
const openShell = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
@@ -354,6 +427,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.empty')} />
)}
@@ -363,6 +439,8 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const rowPending = pendingAction && pendingAction.id === container.id.slice(0, 12)
? pendingAction.action
: null;
const selectedInspectKey = selectedContainer ? getContainerInspectKey(selectedContainer) : null;
const selectedInspectRecord = selectedInspectKey ? inspectByContainerId[selectedInspectKey] : undefined;
return (
<React.Fragment key={container.id}>
<DockerContainerRow
@@ -378,12 +456,13 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
{selectedContainer && (
<DockerContainerDetail
container={selectedContainer}
sessionId={sessionId}
backend={backend}
statsRefreshIntervalSec={statsRefreshIntervalSec}
inspect={inspect}
inspect={selectedInspectRecord?.data ?? null}
inspectError={selectedInspectRecord?.error ?? null}
inspectLoading={selectedInspectRecord?.loading ?? false}
stat={statsByContainerId.get(selectedContainer.id) ?? statsByContainerId.get(selectedContainer.id.slice(0, 12)) ?? null}
statsLoading={statsLoading}
pendingAction={rowPending}
onCloseInspect={() => { setSelectedId(null); setInspect(null); }}
onCloseInspect={() => { setSelectedId(null); }}
onRunAction={runAction}
/>
)}

View File

@@ -1,5 +1,5 @@
import { Layers, Tag, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
@@ -11,7 +11,9 @@ import {
SystemPanelCollapsible,
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelRoundButton,
@@ -20,6 +22,7 @@ import {
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { showSystemManagerError } from './systemManagerToast';
@@ -28,6 +31,7 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
interface DockerImagesPanelProps {
sessionId: string;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
}
@@ -81,6 +85,7 @@ const DockerImageRow = memo(function DockerImageRow({
export const DockerImagesPanel = memo(function DockerImagesPanel({
sessionId,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
}: DockerImagesPanelProps) {
@@ -88,8 +93,12 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const stableT = useStableTranslate();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [inspect, setInspect] = useState<Record<string, unknown> | null>(null);
const inspectSeqRef = useRef(0);
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
useEffect(() => {
setSelectedId(null);
setTagTarget(null);
}, [sessionId]);
const imagesFetcher = useCallback(async () => {
const result = await backend.listDockerImages(sessionId);
@@ -103,8 +112,9 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
imagesFetcher,
listIntervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
@@ -130,6 +140,33 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
);
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
`${sessionId}:${dockerImageRowKey(image)}`
), [sessionId]);
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
});
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, sessionId, stableT]);
const {
records: inspectByImageKey,
loadRecord: loadImageInspect,
invalidateRecord: invalidateImageInspect,
} = useAsyncRecordCache<DockerImageInfo, Record<string, unknown>>({
items: images ?? [],
enabled: isVisible && (images?.length ?? 0) > 0,
getKey: getImageInspectKey,
fetchRecord: fetchImageInspect,
prefetchLimit: 24,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const handleRemove = useCallback(async (image: DockerImageInfo) => {
const label = image.name || image.id.slice(0, 12);
const ok = window.confirm(t('systemManager.docker.confirmRemoveImage', { name: label }));
@@ -146,11 +183,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
}
if (selectedId === dockerImageRowKey(image)) {
setSelectedId(null);
setInspect(null);
inspectSeqRef.current += 1;
}
invalidateImageInspect(getImageInspectKey(image));
await refresh();
}, [backend, refresh, selectedId, sessionId, t]);
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
const handlePrune = async (all: boolean) => {
const ok = window.confirm(all
@@ -165,8 +201,6 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
await refresh();
};
const [tagTarget, setTagTarget] = useState<DockerImageInfo | null>(null);
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
const result = await backend.dockerImageAction({
sessionId,
@@ -182,20 +216,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
await refresh();
};
const selectImage = useCallback(async (image: DockerImageInfo) => {
const selectImage = useCallback((image: DockerImageInfo) => {
const rowKey = dockerImageRowKey(image);
const next = selectedId === rowKey ? null : rowKey;
setSelectedId(next);
setInspect(null);
const seq = ++inspectSeqRef.current;
if (!next) return;
const result = await backend.dockerImageInspect({
sessionId,
imageId: image.id.slice(0, 12),
});
if (inspectSeqRef.current !== seq) return;
setInspect(result.success ? (result.inspect ?? null) : null);
}, [backend, selectedId, sessionId]);
void loadImageInspect(image, { force: true, urgent: true });
}, [loadImageInspect, selectedId]);
const openTagDialog = useCallback((image: DockerImageInfo) => {
setTagTarget(image);
@@ -243,12 +270,16 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Layers} message={t('systemManager.docker.imagesEmpty')} />
)}
{displayList.map((image) => {
const rowKey = dockerImageRowKey(image);
const inspectKey = getImageInspectKey(image);
const shortId = image.id.slice(0, 12);
const displayName = image.repository && image.tag
? `${image.repository}:${image.tag}`
@@ -266,11 +297,20 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
onRemove={handleRemove}
/>
<SystemPanelCollapsible open={selected}>
{inspect && (
{inspectByImageKey[inspectKey]?.loading && !inspectByImageKey[inspectKey]?.data && (
<div className="flex items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3 py-2 text-[10px] text-muted-foreground">
<Loader2 size={11} className="animate-spin" />
{t('systemManager.common.loadingDetails')}
</div>
)}
{inspectByImageKey[inspectKey]?.error && !inspectByImageKey[inspectKey]?.data && (
<SystemPanelInlineError message={inspectByImageKey[inspectKey].error} />
)}
{inspectByImageKey[inspectKey]?.data && (
<DockerInspectView
kind="image"
data={inspect}
onClose={() => { setSelectedId(null); setInspect(null); }}
data={inspectByImageKey[inspectKey].data}
onClose={() => { setSelectedId(null); }}
/>
)}
</SystemPanelCollapsible>

View File

@@ -23,7 +23,7 @@ function InspectList({ label, items }: { label: string; items: string[] }) {
return (
<div className="text-[10px] leading-relaxed">
<div className="text-muted-foreground mb-0.5">{label}</div>
<div className="space-y-0.5 max-h-28 overflow-y-auto font-mono">
<div className="space-y-0.5 font-mono">
{items.map((item, index) => (
<div key={index} className="break-all text-foreground/90">{item}</div>
))}

View File

@@ -15,6 +15,7 @@ interface DockerManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
listRefreshIntervalSec: number;
statsRefreshIntervalSec: number;
@@ -24,6 +25,7 @@ export const DockerManagerTab = memo(function DockerManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
listRefreshIntervalSec,
statsRefreshIntervalSec,
@@ -58,23 +60,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
</div>
<div className="flex-1 min-h-0 flex flex-col">
{subTab === 'containers' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
sessionId={sessionId}
parentSession={parentSession}
isVisible={isVisible}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
statsRefreshIntervalSec={statsRefreshIntervalSec}
/>
) : (
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
<DockerImagesPanel
sessionId={sessionId}
isVisible={isVisible}
isVisible={isVisible && subTab === 'images'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
/>
)}
</div>
</div>
</SystemPanelShell>
);

View File

@@ -1,7 +1,7 @@
import {
Gauge, LayoutList, Pause, Play, Skull, XCircle,
Gauge, LayoutList, Loader2, Pause, Play, Skull, XCircle,
} from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import {
@@ -12,6 +12,7 @@ import {
import type { SystemProcessInfo } from '../../domain/systemManager/types';
import { systemProcessInfoEqual } from '../../domain/systemManager/pollEquals';
import { cn } from '../../lib/utils';
import { VariableSizeVirtualList } from '../ui/VariableSizeVirtualList';
import { ResourceBar } from './ResourceBar';
import { useStableListOrder, mergePollListByKey } from './listStable';
import {
@@ -27,7 +28,6 @@ import {
SystemPanelSearch,
SystemPanelSegmented,
SystemPanelShell,
SystemPanelCollapsible,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
@@ -38,6 +38,16 @@ type Backend = ReturnType<typeof useSystemManagerBackend>;
type SortKey = 'cpuPercent' | 'memPercent' | 'pid' | 'command' | 'user';
type ProcessFilter = 'all' | 'running';
const PROCESS_CACHE_TTL_MS = 30_000;
const PROCESS_ROW_HEIGHT = 56;
const PROCESS_DETAIL_HEIGHT = 112;
const PROCESS_OVERSCAN_ROWS = 8;
const processListCache = new Map<string, {
processes: SystemProcessInfo[];
updatedAt: number;
}>();
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
{ key: 'cpuPercent', labelKey: 'systemManager.processes.sort.cpu' },
{ key: 'memPercent', labelKey: 'systemManager.processes.sort.mem' },
@@ -61,6 +71,29 @@ const mergeProcesses = (
next: SystemProcessInfo[],
) => mergePollListByKey(prev, next, (p) => p.pid, systemProcessInfoEqual);
function getCachedProcesses(sessionId: string): SystemProcessInfo[] | null {
const cached = processListCache.get(sessionId);
if (!cached) return null;
if (Date.now() - cached.updatedAt > PROCESS_CACHE_TTL_MS) {
processListCache.delete(sessionId);
return null;
}
return cached.processes;
}
const ProcessListLoading = memo(function ProcessListLoading({
message,
}: {
message: string;
}) {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
interface ProcessRowProps {
proc: SystemProcessInfo;
selected: boolean;
@@ -79,72 +112,125 @@ const ProcessRow = memo(function ProcessRow({
const { t } = useI18n();
const { isStopped, isZombie } = getProcessFlags(proc);
const actions = (
<div className="flex w-[112px] shrink-0 items-center justify-end gap-1">
{!isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.stop')}
onClick={() => onSignal(proc.pid, 'STOP')}
>
<Pause size={12} />
</SystemPanelRoundButton>
)}
{isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.cont')}
onClick={() => onSignal(proc.pid, 'CONT')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.processes.term')}
onClick={() => onSignal(proc.pid, 'TERM')}
>
<XCircle size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.kill')}
destructive
onClick={() => onSignal(proc.pid, 'KILL')}
>
<Skull size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.renice')}
onClick={() => onRenice(proc.pid)}
>
<Gauge size={12} />
</SystemPanelRoundButton>
</div>
);
return (
<>
<div className="h-full overflow-hidden">
<SystemPanelRow
selected={selected}
onClick={() => onToggle(proc.pid)}
title={proc.command}
subtitle={`${proc.user || '—'} · PID ${proc.pid}`}
className="h-14"
trailing={(
<div className="flex shrink-0 items-center gap-1">
<div className="flex w-[88px] shrink-0 items-center justify-end">
<SystemPanelStatusBadge tone={getProcessTone(proc)}>
{t(getProcessStatusLabelKey(proc))}
</SystemPanelStatusBadge>
{!isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.stop')}
onClick={() => onSignal(proc.pid, 'STOP')}
>
<Pause size={12} />
</SystemPanelRoundButton>
)}
{isStopped && !isZombie && (
<SystemPanelRoundButton
title={t('systemManager.processes.cont')}
onClick={() => onSignal(proc.pid, 'CONT')}
>
<Play size={12} />
</SystemPanelRoundButton>
)}
<SystemPanelRoundButton
title={t('systemManager.processes.term')}
onClick={() => onSignal(proc.pid, 'TERM')}
>
<XCircle size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.kill')}
destructive
onClick={() => onSignal(proc.pid, 'KILL')}
>
<Skull size={12} />
</SystemPanelRoundButton>
<SystemPanelRoundButton
title={t('systemManager.processes.renice')}
onClick={() => onRenice(proc.pid)}
>
<Gauge size={12} />
</SystemPanelRoundButton>
</div>
)}
actions={actions}
/>
<SystemPanelCollapsible open={selected}>
<SystemPanelDetailStrip>
{selected && (
<SystemPanelDetailStrip className="h-28 overflow-hidden">
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[10px] text-muted-foreground mb-2">
<span>{t('systemManager.processes.ppid')}: {proc.ppid}</span>
<span>{t('systemManager.processes.stat')}: {proc.stat}</span>
<span>{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
<span>{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
<span className="col-span-2">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.ppid')}: {proc.ppid}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.stat')}: {proc.stat}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.elapsed')}: {proc.elapsed || '—'}</span>
<span className="min-w-0 truncate">{t('systemManager.processes.rss')}: {formatKb(proc.rssKb)}</span>
<span className="col-span-2 min-w-0 truncate">{t('systemManager.processes.vsz')}: {formatKb(proc.vszKb)}</span>
</div>
<div className="space-y-1">
<ResourceBar label="CPU" value={proc.cpuPercent} />
<ResourceBar label="MEM" value={proc.memPercent} />
</div>
</SystemPanelDetailStrip>
</SystemPanelCollapsible>
</>
)}
</div>
);
});
interface ProcessVirtualListProps {
processes: SystemProcessInfo[];
selectedPid: number | null;
onToggle: (pid: number) => void;
onSignal: (pid: number, signal: string) => void;
onRenice: (pid: number) => void;
}
const ProcessVirtualList = memo(function ProcessVirtualList({
processes,
selectedPid,
onToggle,
onSignal,
onRenice,
}: ProcessVirtualListProps) {
const getItemHeight = useCallback(
(proc: SystemProcessInfo) => (
proc.pid === selectedPid
? PROCESS_ROW_HEIGHT + PROCESS_DETAIL_HEIGHT
: PROCESS_ROW_HEIGHT
),
[selectedPid],
);
const renderItem = useCallback((proc: SystemProcessInfo) => (
<ProcessRow
proc={proc}
selected={selectedPid === proc.pid}
onToggle={onToggle}
onSignal={onSignal}
onRenice={onRenice}
/>
), [onRenice, onSignal, onToggle, selectedPid]);
return (
<VariableSizeVirtualList<SystemProcessInfo>
items={processes}
getItemHeight={getItemHeight}
className="flex-1 min-h-0"
overscan={PROCESS_OVERSCAN_ROWS}
getItemKey={(proc) => String(proc.pid)}
renderItem={renderItem}
/>
);
});
@@ -170,14 +256,52 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
const [selectedPid, setSelectedPid] = useState<number | null>(null);
const [reniceTarget, setReniceTarget] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [cachedProcesses, setCachedProcesses] = useState<SystemProcessInfo[] | null>(() => getCachedProcesses(sessionId));
const [cachedProcessesSessionId, setCachedProcessesSessionId] = useState(sessionId);
const [processListPending, setProcessListPending] = useState(false);
const processFetchGenerationRef = useRef(0);
const currentSessionIdRef = useRef(sessionId);
if (currentSessionIdRef.current !== sessionId) {
currentSessionIdRef.current = sessionId;
processFetchGenerationRef.current += 1;
}
useEffect(() => {
processFetchGenerationRef.current += 1;
setCachedProcesses(getCachedProcesses(sessionId));
setCachedProcessesSessionId(sessionId);
setProcessListPending(false);
}, [sessionId]);
useEffect(() => () => {
processFetchGenerationRef.current += 1;
}, []);
const fetcher = useCallback(async () => {
const result = await backend.listSystemProcesses(sessionId);
if (result.pending) return null;
if (!result.success || !result.processes) {
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
const fetchGeneration = processFetchGenerationRef.current;
const fetchSessionId = sessionId;
const isCurrentFetch = () => (
processFetchGenerationRef.current === fetchGeneration
&& currentSessionIdRef.current === fetchSessionId
);
try {
const result = await backend.listSystemProcesses(sessionId);
if (!isCurrentFetch()) return null;
if (result.pending) {
setProcessListPending(true);
return null;
}
setProcessListPending(false);
if (!result.success || !result.processes) {
throw new Error(result.error || stableT('systemManager.errors.loadProcesses'));
}
return result.processes;
} catch (err) {
if (!isCurrentFetch()) return null;
setProcessListPending(false);
throw err;
}
return result.processes;
}, [backend, sessionId, stableT]);
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
@@ -186,10 +310,24 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
intervalMs,
isVisible,
mergeProcesses,
{ resetKey: sessionId },
);
const matched = useMemo(() => {
const list = processes ?? [];
useEffect(() => {
if (!processes) return;
processListCache.set(sessionId, { processes, updatedAt: Date.now() });
setCachedProcesses(processes);
setCachedProcessesSessionId(sessionId);
}, [processes, sessionId]);
const sessionCachedProcesses = cachedProcessesSessionId === sessionId
? cachedProcesses
: getCachedProcesses(sessionId);
const visibleProcesses = processes ?? sessionCachedProcesses;
const showingCachedProcesses = processes === null && sessionCachedProcesses !== null;
const matched = useMemo<SystemProcessInfo[]>(() => {
const list = visibleProcesses ?? [];
const q = query.trim().toLowerCase();
return list.filter((p) => {
if (filter === 'running' && !isProcessRunning(p.stat)) return false;
@@ -199,7 +337,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
|| p.user.toLowerCase().includes(q)
|| p.command.toLowerCase().includes(q);
});
}, [processes, query, filter]);
}, [visibleProcesses, query, filter]);
const compareProcesses = useCallback((a: SystemProcessInfo, b: SystemProcessInfo) => {
let cmp = 0;
@@ -216,7 +354,16 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
}, [sortAsc, sortKey]);
const sortToken = `${sortKey}|${sortAsc}|${filter}|${query}`;
const displayList = useStableListOrder(matched, (p) => p.pid, sortToken, compareProcesses);
const displayList = useStableListOrder<SystemProcessInfo, number>(
matched,
(p) => p.pid,
sortToken,
compareProcesses,
);
const isProcessRefreshActive = loading || processListPending;
const showInitialLoading = isProcessRefreshActive && displayList.length === 0;
const showBlockingError = Boolean(error && !isProcessRefreshActive && displayList.length === 0);
const showInlineRefreshError = Boolean(error && !isProcessRefreshActive && displayList.length > 0);
const cycleSort = (key: SortKey) => {
if (sortKey === key) setSortAsc((v) => !v);
@@ -265,7 +412,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={loading}
loading={isProcessRefreshActive}
onClick={() => void refresh()}
/>
)}
@@ -305,29 +452,36 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
))}
</div>
)}>
{t('systemManager.processes.meta', { count: String(displayList.length) })}
<span className={cn(showingCachedProcesses && isProcessRefreshActive && 'inline-flex items-center gap-1.5')}>
{showingCachedProcesses && isProcessRefreshActive && <Loader2 size={10} className="animate-spin" />}
{t('systemManager.processes.meta', { count: String(displayList.length) })}
</span>
</SystemPanelMetaBar>
{actionError && <SystemPanelInlineError message={actionError} />}
{showInlineRefreshError && error && <SystemPanelInlineError message={error} />}
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
)}
{displayList.map((proc) => (
<ProcessRow
key={proc.pid}
proc={proc}
selected={selectedPid === proc.pid}
onToggle={togglePid}
onSignal={signalProcess}
onRenice={openRenicePrompt}
/>
))}
</SystemPanelList>
{(showBlockingError || showInitialLoading || (!error && displayList.length === 0 && !loading && !showInitialLoading)) ? (
<SystemPanelList>
{showBlockingError && error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{showInitialLoading && (
<ProcessListLoading message={t('systemManager.processes.loading')} />
)}
{!error && displayList.length === 0 && !loading && !showInitialLoading && (
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />
)}
</SystemPanelList>
) : (
<ProcessVirtualList
processes={displayList}
selectedPid={selectedPid}
onToggle={togglePid}
onSignal={signalProcess}
onRenice={openRenicePrompt}
/>
)}
<SystemPanelPromptDialog
open={reniceTarget !== null}

View File

@@ -1,4 +1,4 @@
import { Activity, Box, LayoutList, TerminalSquare } from 'lucide-react';
import { Activity, Box, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
@@ -15,6 +15,19 @@ import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHos
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from './hooks/useSystemManager';
const SystemPanelChecking = memo(function SystemPanelChecking({
message,
}: {
message: string;
}) {
return (
<div className="flex h-full min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
interface SystemManagerSidePanelProps {
session: TerminalSession | null;
sessionHost: Host | null;
@@ -82,6 +95,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const dockerReady = capabilities?.hasDocker === true;
const tmuxUnavailable = !probing && capabilities !== undefined && !tmuxReady;
const dockerUnavailable = !probing && capabilities !== undefined && !dockerReady;
const tmuxChecking = resolvedTab === 'tmux' && !tmuxReady && !tmuxUnavailable;
const dockerChecking = resolvedTab === 'docker' && !dockerReady && !dockerUnavailable;
return (
<SystemPanelShell section="system-manager-panel">
@@ -106,42 +121,56 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
</div>
<div className="flex-1 min-h-0 flex flex-col">
{resolvedTab === 'processes' && (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
isVisible={isVisible}
isVisible={isVisible && resolvedTab === 'processes'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
)}
{resolvedTab === 'tmux' && (
tmuxUnavailable ? (
</div>
{tmuxUnavailable && resolvedTab === 'tmux' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
) : (
</div>
) : tmuxChecking ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : tmuxReady ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
<TmuxManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && tmuxReady}
isVisible={isVisible && resolvedTab === 'tmux'}
warmupEnabled={isVisible && resolvedTab !== 'tmux'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerTmuxRefreshInterval}
snippets={snippets}
/>
)
)}
{resolvedTab === 'docker' && (
dockerUnavailable ? (
</div>
) : null}
{dockerUnavailable && resolvedTab === 'docker' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
) : (
</div>
) : dockerChecking ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : dockerReady ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
<DockerManagerTab
sessionId={sessionId}
parentSession={session}
isVisible={isVisible && dockerReady}
isVisible={isVisible && resolvedTab === 'docker'}
warmupEnabled={isVisible && resolvedTab !== 'docker'}
backend={backend}
listRefreshIntervalSec={terminalSettings.systemManagerDockerListRefreshInterval}
statsRefreshIntervalSec={terminalSettings.systemManagerDockerStatsRefreshInterval}
/>
)
)}
</div>
) : null}
</div>
</SystemPanelShell>
);

View File

@@ -203,6 +203,19 @@ export const SystemPanelEmpty = memo(function SystemPanelEmpty({
);
});
export const SystemPanelLoading = memo(function SystemPanelLoading({
message,
}: {
message: string;
}) {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center px-4 py-10 text-center text-xs text-muted-foreground">
<Loader2 size={18} className="mb-2 animate-spin opacity-70" />
<span>{message}</span>
</div>
);
});
export const SystemPanelError = memo(function SystemPanelError({
message,
onRetry,
@@ -270,6 +283,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
subtitle,
trailing,
actions,
className,
}: {
selected?: boolean;
onClick?: () => void;
@@ -279,6 +293,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
subtitle?: ReactNode;
trailing?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
const content = (
<>
@@ -292,7 +307,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
{trailing}
{actions && (
<div
className="flex shrink-0 items-center justify-end gap-0.5 invisible group-hover:visible group-focus-within:visible"
className="flex shrink-0 items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
>
{actions}
@@ -301,10 +316,11 @@ export const SystemPanelRow = memo(function SystemPanelRow({
</>
);
const className = cn(
const rowClassName = cn(
'group flex items-center gap-2.5 pr-2.5 py-2.5 min-h-[44px] border-b border-border/30',
selected && 'bg-accent/30',
onClick && 'cursor-pointer hover:bg-accent/50',
className,
);
const style = { paddingLeft: 12 + depth * 14 };
@@ -315,7 +331,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
<div
role="button"
tabIndex={0}
className={cn('w-full text-left', className)}
className={cn('w-full text-left', rowClassName)}
style={style}
onClick={onClick}
onKeyDown={(e) => {
@@ -332,7 +348,7 @@ export const SystemPanelRow = memo(function SystemPanelRow({
}
return (
<div className={className} style={style}>
<div className={rowClassName} style={style}>
{content}
</div>
);

View File

@@ -1,21 +1,23 @@
import { Plus, TerminalSquare } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type { Snippet, TerminalSession } from '../../types';
import type { TmuxSessionInfo } from '../../domain/systemManager/types';
import type { TmuxClientInfo, TmuxSessionInfo, TmuxWindowInfo } from '../../domain/systemManager/types';
import { tmuxSessionInfoEqual } from '../../domain/systemManager/pollEquals';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelIconButton,
SystemPanelList,
SystemPanelLoading,
SystemPanelMetaBar,
SystemPanelRefreshButton,
SystemPanelSearch,
SystemPanelShell,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { TmuxNewSessionModal } from './TmuxNewSessionModal';
import { TmuxSessionCard } from './TmuxSessionCard';
@@ -23,10 +25,16 @@ import { useStableListOrder, mergePollListByKey } from './listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
export interface TmuxSessionDetails {
windows: TmuxWindowInfo[];
clients: TmuxClientInfo[];
}
interface TmuxManagerTabProps {
sessionId: string;
parentSession: TerminalSession;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
refreshIntervalSec: number;
snippets: Snippet[];
@@ -36,6 +44,7 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
sessionId,
parentSession,
isVisible,
warmupEnabled = false,
backend,
refreshIntervalSec,
snippets,
@@ -48,11 +57,20 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
const [modalError, setModalError] = useState<string | null>(null);
const [tmuxVersion, setTmuxVersion] = useState<string | null>(null);
const currentSessionIdRef = useRef(sessionId);
currentSessionIdRef.current = sessionId;
useEffect(() => {
setTmuxVersion(null);
}, [sessionId]);
const fetcher = useCallback(async () => {
const fetchSessionId = sessionId;
const result = await backend.listTmuxSessions(sessionId);
const version = result.tmuxVersion ?? null;
setTmuxVersion((prev) => (prev === version ? prev : version));
if (currentSessionIdRef.current === fetchSessionId) {
setTmuxVersion((prev) => (prev === version ? prev : version));
}
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadTmux'));
}
@@ -63,11 +81,12 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
const { data: sessions, error, loading, refresh } = usePolling<TmuxSessionInfo[]>(
fetcher,
intervalMs,
isVisible,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (s) => s.name, tmuxSessionInfoEqual),
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
const filtered = useMemo<TmuxSessionInfo[]>(() => {
const q = query.trim().toLowerCase();
const list = sessions ?? [];
if (!q) return list;
@@ -78,13 +97,69 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
(a: TmuxSessionInfo, b: TmuxSessionInfo) => a.name.localeCompare(b.name),
[],
);
const displaySessions = useStableListOrder(
const displaySessions = useStableListOrder<TmuxSessionInfo, string>(
filtered,
(s) => s.name,
query,
compareSessions,
);
const formatTmuxLoadError = useCallback((
message: string,
debug?: { lastOutput?: string; tried?: string[] },
) => {
const parts = [message];
if (debug?.lastOutput) parts.push(debug.lastOutput);
if (debug?.tried?.length) {
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
}
return parts.filter(Boolean).join(' · ');
}, [t]);
const getTmuxDetailsKey = useCallback((session: TmuxSessionInfo) => (
`${sessionId}:${session.name}:${session.created}`
), [sessionId]);
const fetchTmuxDetails = useCallback(async (session: TmuxSessionInfo): Promise<TmuxSessionDetails> => {
const [windowsResult, clientsResult] = await Promise.all([
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
backend.listTmuxClients({ sessionId, sessionName: session.name }),
]);
if (!windowsResult.success) {
throw new Error(formatTmuxLoadError(
windowsResult.error || stableT('systemManager.errors.loadTmuxWindows'),
windowsResult.debug,
));
}
if (!clientsResult.success) {
throw new Error(clientsResult.error || stableT('systemManager.errors.loadTmuxClients'));
}
const freshWindows = windowsResult.windows ?? [];
if (freshWindows.length === 0 && session.windows > 0) {
throw new Error(formatTmuxLoadError(
stableT('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
windowsResult.debug,
));
}
return {
windows: freshWindows,
clients: clientsResult.clients ?? [],
};
}, [backend, formatTmuxLoadError, sessionId, stableT]);
const {
records: tmuxDetailsByName,
loadRecord: loadTmuxDetails,
refreshRecord: refreshTmuxDetails,
} = useAsyncRecordCache<TmuxSessionInfo, TmuxSessionDetails>({
items: sessions ?? [],
enabled: isVisible && (sessions?.length ?? 0) > 0,
getKey: getTmuxDetailsKey,
fetchRecord: fetchTmuxDetails,
prefetchLimit: 16,
prefetchDelayMs: 40,
staleTimeMs: 20_000,
});
const handleCreate = useCallback(async (name: string, command: string) => {
setCreating(true);
setModalError(null);
@@ -140,6 +215,9 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
</SystemPanelMetaBar>
<SystemPanelList>
{!error && displaySessions.length === 0 && loading && (
<SystemPanelLoading message={t('systemManager.common.loading')} />
)}
{!error && displaySessions.length === 0 && !loading && (
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.empty')} />
)}
@@ -148,11 +226,14 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
)}
{displaySessions.map((session) => (
<TmuxSessionCard
key={session.name}
key={`${session.name}:${session.created}`}
session={session}
sessionId={sessionId}
parentSession={parentSession}
backend={backend}
detailsRecord={tmuxDetailsByName[getTmuxDetailsKey(session)]}
onLoadDetails={loadTmuxDetails}
onRefreshDetails={refreshTmuxDetails}
onSessionsChanged={refresh}
/>
))}

View File

@@ -1,17 +1,17 @@
import {
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
import type {
TmuxClientInfo,
TmuxManageAction,
TmuxSessionInfo,
TmuxWindowInfo,
} from '../../domain/systemManager/types';
import type { TerminalSession } from '../../types';
import type { AsyncRecordState } from './hooks/useAsyncRecordCache';
import type { TmuxSessionDetails } from './TmuxManagerTab';
import {
SystemPanelCollapsible,
SystemPanelDetailStrip,
@@ -46,6 +46,9 @@ interface TmuxSessionCardProps {
sessionId: string;
parentSession: TerminalSession;
backend: Backend;
detailsRecord?: AsyncRecordState<TmuxSessionDetails>;
onLoadDetails: (session: TmuxSessionInfo, options?: { force?: boolean; urgent?: boolean }) => Promise<void>;
onRefreshDetails: (session: TmuxSessionInfo) => Promise<void>;
onSessionsChanged: () => Promise<void>;
}
@@ -54,74 +57,42 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
sessionId,
parentSession,
backend,
detailsRecord,
onLoadDetails,
onRefreshDetails,
onSessionsChanged,
}: TmuxSessionCardProps) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [loadingDetails, setLoadingDetails] = useState(false);
const [windows, setWindows] = useState<TmuxWindowInfo[]>([]);
const [clients, setClients] = useState<TmuxClientInfo[]>([]);
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
const [newWindowOpen, setNewWindowOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [windowsLoadDetail, setWindowsLoadDetail] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<PendingTarget | null>(null);
const formatTmuxLoadError = useCallback((
message: string,
debug?: { lastOutput?: string; tried?: string[] },
) => {
const parts = [message];
if (debug?.lastOutput) parts.push(debug.lastOutput);
if (debug?.tried?.length) {
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
}
return parts.filter(Boolean).join(' · ');
}, [t]);
const loadDetails = useCallback(async (): Promise<TmuxWindowInfo[] | null> => {
setLoadingDetails(true);
setActionError(null);
setWindowsLoadDetail(null);
try {
const [windowsResult, clientsResult] = await Promise.all([
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
backend.listTmuxClients({ sessionId, sessionName: session.name }),
]);
if (!windowsResult.success) {
const detail = formatTmuxLoadError(
windowsResult.error || t('systemManager.errors.loadTmuxWindows'),
windowsResult.debug,
);
setWindowsLoadDetail(detail);
throw new Error(detail);
}
if (!clientsResult.success) throw new Error(clientsResult.error || t('systemManager.errors.loadTmuxClients'));
const freshWindows = windowsResult.windows ?? [];
if (freshWindows.length === 0 && session.windows > 0) {
const detail = formatTmuxLoadError(
t('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
windowsResult.debug,
);
setWindowsLoadDetail(detail);
throw new Error(detail);
}
setWindows(freshWindows);
setClients(clientsResult.clients ?? []);
return freshWindows;
} catch (err) {
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
setWindows([]);
return null;
} finally {
setLoadingDetails(false);
}
}, [backend, formatTmuxLoadError, session.name, session.windows, sessionId, t]);
const windows = detailsRecord?.data?.windows ?? [];
const clients = detailsRecord?.data?.clients ?? [];
const loadingDetails = detailsRecord?.loading ?? false;
const windowsLoadDetail = detailsRecord?.error ?? null;
const summaryKey = useMemo(
() => `${session.name}|${session.created}|${session.windows}|${session.attached}|${session.activity ?? ''}`,
[session.activity, session.attached, session.created, session.name, session.windows],
);
const lastExpandedSummaryKeyRef = useRef<string | null>(null);
useEffect(() => {
if (expanded) void loadDetails();
}, [expanded, loadDetails]);
if (!expanded) {
lastExpandedSummaryKeyRef.current = null;
return;
}
if (lastExpandedSummaryKeyRef.current === null) {
lastExpandedSummaryKeyRef.current = summaryKey;
return;
}
if (lastExpandedSummaryKeyRef.current === summaryKey) return;
lastExpandedSummaryKeyRef.current = summaryKey;
void onRefreshDetails(session);
}, [expanded, onRefreshDetails, session, summaryKey]);
const runAction = async (action: TmuxManageAction) => {
setBusy(true);
@@ -135,7 +106,7 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
if (!cardWillRemount && expanded) {
await loadDetails();
await onRefreshDetails(session);
}
await onSessionsChanged();
} catch (err) {
@@ -170,7 +141,13 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
<>
<SystemPanelRow
selected={expanded}
onClick={() => setExpanded((v) => !v)}
onClick={() => {
const nextExpanded = !expanded;
setExpanded(nextExpanded);
if (nextExpanded) {
void onLoadDetails(session, { force: true, urgent: true });
}
}}
title={session.name}
subtitle={t('systemManager.tmux.windows', { count: String(session.windows) })}
trailing={(

View File

@@ -0,0 +1,269 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
export interface AsyncRecordState<T> {
data: T | null;
loading: boolean;
error: string | null;
updatedAt: number | null;
}
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
interface UseAsyncRecordCacheOptions<TItem, TValue> {
items: TItem[];
enabled: boolean;
getKey: (item: TItem) => string;
fetchRecord: (item: TItem) => Promise<TValue | null>;
prefetchLimit?: number;
prefetchDelayMs?: number;
staleTimeMs?: number;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleIdleTask(callback: () => void): () => void {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 80);
return () => window.clearTimeout(id);
}
function normalizeRecordError(error: unknown): string {
return error instanceof Error ? error.message : String(error || 'Unknown error');
}
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
if (!record || record.error || record.updatedAt === null) return false;
if (!Number.isFinite(staleTimeMs)) return true;
return Date.now() - record.updatedAt < staleTimeMs;
}
const EMPTY_RECORDS = {};
export function useAsyncRecordCache<TItem, TValue>({
items,
enabled,
getKey,
fetchRecord,
prefetchLimit = 64,
prefetchDelayMs = 16,
staleTimeMs = 30_000,
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
const recordsRef = useRef<RecordMap<TValue>>(records);
const enabledRef = useRef(enabled);
const inflightRef = useRef(new Set<string>());
const requestVersionRef = useRef(new Map<string, number>());
const queuedForceRef = useRef(new Set<string>());
const loadRecordRef = useRef<(
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => Promise<void>>(async () => {});
recordsRef.current = records;
enabledRef.current = enabled;
const commitRecords = useCallback((
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
urgent = false,
) => {
const apply = () => {
setRecords((prev) => {
const next = updater(prev);
recordsRef.current = next;
return next;
});
};
if (urgent) {
apply();
return;
}
startTransition(apply);
}, []);
const loadRecord = useCallback(async (
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => {
if (!enabledRef.current) return;
const key = getKey(item);
if (!key) return;
if (inflightRef.current.has(key)) {
if (options?.force) {
queuedForceRef.current.add(key);
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
}
return;
}
const existing = recordsRef.current[key];
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
return;
}
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
requestVersionRef.current.set(key, requestVersion);
inflightRef.current.add(key);
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: true,
error: null,
updatedAt: prev[key]?.updatedAt ?? null,
},
}), options?.urgent);
try {
const data = await fetchRecord(item);
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data,
loading: false,
error: null,
updatedAt: Date.now(),
},
}));
} catch (error) {
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: false,
error: normalizeRecordError(error),
updatedAt: prev[key]?.updatedAt ?? null,
},
}));
} finally {
inflightRef.current.delete(key);
if (queuedForceRef.current.has(key)) {
if (enabledRef.current) {
queuedForceRef.current.delete(key);
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
} else {
commitRecords((prev) => {
const current = prev[key];
if (!current?.loading) return prev;
return {
...prev,
[key]: {
...current,
loading: false,
},
};
}, true);
}
}
}
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
loadRecordRef.current = loadRecord;
useEffect(() => {
const itemKeys = new Set(items.map(getKey).filter(Boolean));
for (const key of queuedForceRef.current) {
if (!itemKeys.has(key)) {
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (!itemKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
});
}, [commitRecords, getKey, items]);
useEffect(() => {
if (!enabled || queuedForceRef.current.size === 0) return;
for (const item of items) {
const key = getKey(item);
if (!key || !queuedForceRef.current.has(key)) continue;
queuedForceRef.current.delete(key);
void loadRecord(item, { force: true, urgent: true });
}
}, [enabled, getKey, items, loadRecord]);
useEffect(() => {
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
let cancelled = false;
const candidates = items.slice(0, prefetchLimit);
const cancelIdleTask = scheduleIdleTask(() => {
void (async () => {
for (const item of candidates) {
if (cancelled) return;
await loadRecord(item);
if (prefetchDelayMs > 0) {
await delay(prefetchDelayMs);
}
}
})();
});
return () => {
cancelled = true;
cancelIdleTask();
};
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
const invalidateRecord = useCallback((key: string) => {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
commitRecords((prev) => {
if (!(key in prev)) return prev;
const { [key]: _removed, ...next } = prev;
return next;
}, true);
}, [commitRecords]);
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
for (const key of requestVersionRef.current.keys()) {
if (matches(key)) {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (matches(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
}, true);
}, [commitRecords]);
const refreshRecord = useCallback(
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
[loadRecord],
);
return {
records,
loadRecord,
refreshRecord,
invalidateRecord,
invalidateMatching,
};
}

View File

@@ -113,20 +113,37 @@ export function usePolling<T>(
intervalMs: number,
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
options?: { poll?: boolean; resetKey?: string },
) {
const stableT = useStableTranslate();
const resetKey = options?.resetKey ?? '';
const [data, setData] = useState<T | null>(null);
const [dataKey, setDataKey] = useState(resetKey);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState(resetKey);
const [loading, setLoading] = useState(false);
const [loadingKey, setLoadingKey] = useState(resetKey);
const failuresRef = useRef(0);
const hasDataRef = useRef(false);
const inflightRef = useRef(false);
const enabledRef = useRef(enabled);
const generationRef = useRef(0);
const runIdRef = useRef(0);
const loadingRunIdRef = useRef(0);
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
const queuedRunRef = useRef<{
options?: { withLoading?: boolean; minLoadingMs?: number };
resolve: () => void;
} | null>(null);
const fetcherRef = useRef(fetcher);
const mergeRef = useRef(merge);
const pollRef = useRef(options?.poll ?? true);
const resetKeyRef = useRef(resetKey);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
enabledRef.current = enabled;
fetcherRef.current = fetcher;
mergeRef.current = merge;
pollRef.current = options?.poll ?? true;
const clearPollTimer = useCallback(() => {
if (timerRef.current !== null) {
@@ -140,70 +157,163 @@ export function usePolling<T>(
return intervalMs;
}, [intervalMs]);
const resolveQueuedRun = useCallback(() => {
queuedRunRef.current?.resolve();
queuedRunRef.current = null;
}, []);
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
if (!enabled || inflightRef.current) return;
inflightRef.current = true;
const generation = generationRef.current;
const runResetKey = resetKeyRef.current;
if (!enabledRef.current) return;
if (inflightRef.current?.generation === generation) {
if (options?.withLoading) {
queuedRunRef.current?.resolve();
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(true);
return new Promise<void>((resolve) => {
queuedRunRef.current = { options, resolve };
});
}
return;
}
const runId = ++runIdRef.current;
inflightRef.current = { generation, runId };
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
if (showLoading) setLoading(true);
const isCurrent = () => (
generationRef.current === generation
&& enabledRef.current
&& inflightRef.current?.runId === runId
&& resetKeyRef.current === runResetKey
);
if (showLoading) {
loadingRunIdRef.current = runId;
setLoadingKey(runResetKey);
setLoading(true);
}
try {
const result = await fetcherRef.current();
if (!isCurrent()) return;
if (result !== null) {
setDataKey(runResetKey);
setData((prev) => {
const mergeFn = mergeRef.current;
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
if (next !== prev) hasDataRef.current = true;
return next;
});
setErrorKey(runResetKey);
setError(null);
failuresRef.current = 0;
}
} catch (err) {
if (!isCurrent()) return;
failuresRef.current += 1;
setDataKey(runResetKey);
setData(null);
hasDataRef.current = false;
setErrorKey(runResetKey);
setError(normalizePollingErrorMessage(err, stableT));
} finally {
inflightRef.current = false;
if (inflightRef.current?.runId === runId) {
inflightRef.current = null;
}
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
setLoading(false);
if (
generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
&& loadingRunIdRef.current === runId
) {
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(false);
}
}
const queued = queuedRunRef.current;
if (
queued
&& generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
) {
queuedRunRef.current = null;
await run(queued.options);
queued.resolve();
}
}
}, [enabled, stableT]);
}, [stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
if (!enabled) return;
if (!enabledRef.current || !pollRef.current) return;
const generation = generationRef.current;
timerRef.current = setTimeout(() => {
void run({ withLoading: false }).finally(() => {
scheduleNextPoll();
if (generationRef.current === generation) {
scheduleNextPoll();
}
});
}, pollDelayMs());
}, [clearPollTimer, enabled, pollDelayMs, run]);
}, [clearPollTimer, pollDelayMs, run]);
useEffect(() => {
const resetChanged = resetKeyRef.current !== resetKey;
resetKeyRef.current = resetKey;
generationRef.current += 1;
inflightRef.current = null;
clearPollTimer();
if (!enabled) {
clearPollTimer();
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
return undefined;
}
if (resetChanged) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
}
const generation = generationRef.current;
void run({ withLoading: true }).finally(() => {
scheduleNextPoll();
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
});
return () => {
generationRef.current += 1;
resolveQueuedRun();
loadingRunIdRef.current = 0;
inflightRef.current = null;
clearPollTimer();
};
}, [clearPollTimer, enabled, intervalMs, run, scheduleNextPoll]);
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return { data, error, loading, refresh };
return {
data: dataKey === resetKey ? data : null,
error: errorKey === resetKey ? error : null,
loading: loadingKey === resetKey ? loading : enabled,
refresh,
};
}

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
getTerminalTimestampTypography,
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from "./TerminalTimestampGutter.tsx";
test("timestamp gutter uses a bright color from the active terminal theme", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightCyan: "#66e8ff",
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#66e8ff",
);
});
test("timestamp gutter falls back within the terminal theme palette", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#ffe066",
);
assert.equal(
resolveTerminalTimestampGutterColor({
foreground: "#dddddd",
}),
"#dddddd",
);
});
test("timestamp gutter width follows measured timestamp text width", () => {
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 84, fontSize: 14 }),
84 + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
);
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 1, fontSize: 14 }),
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
);
});
test("timestamp gutter typography follows terminal typography", () => {
assert.deepEqual(
getTerminalTimestampTypography({
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
}),
{
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
},
);
});

View File

@@ -0,0 +1,281 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
getVisibleTerminalLineTimestampRows,
onTerminalLineTimestampsChange,
} from "./runtime/terminalLineTimestamps";
export const TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH = 56;
export const TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING = 16;
export const TERMINAL_TIMESTAMP_SAMPLE_LABEL = "88:88:88";
type TerminalTimestampGutterProps = {
termRef: RefObject<XTerm | null>;
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
top: string;
sessionId: string;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
width: number;
onWidthChange?: (width: number) => void;
};
type DisposableLike = {
dispose: () => void;
};
type TerminalTimestampTypography = {
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
};
const getTerminalScreen = (container: HTMLElement): HTMLElement => (
container.querySelector<HTMLElement>(".xterm-screen") ?? container
);
const clearElement = (element: HTMLElement) => {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
};
export const resolveTerminalTimestampGutterColor = (
colors: Partial<Record<"brightCyan" | "brightYellow" | "brightMagenta" | "foreground", string>>,
): string => (
colors.brightCyan
|| colors.brightYellow
|| colors.brightMagenta
|| colors.foreground
|| "currentColor"
);
const normalizeTerminalTimestampFontSize = (fontSize?: number): number => (
Number.isFinite(fontSize) && fontSize && fontSize > 0 ? fontSize : 12
);
export const getTerminalTimestampTypography = ({
fontFamily,
fontSize,
fontWeight,
}: TerminalTimestampTypography) => ({
fontFamily: fontFamily || "monospace",
fontSize: normalizeTerminalTimestampFontSize(fontSize),
fontWeight: fontWeight ?? 400,
});
const estimateTerminalTimestampTextWidth = (
fontSize: number,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
): number => (
normalizeTerminalTimestampFontSize(fontSize) * label.length * 0.62
);
export const resolveTerminalTimestampGutterWidth = ({
measuredTextWidth,
fontSize,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
}: {
measuredTextWidth?: number;
fontSize?: number;
label?: string;
}): number => {
const textWidth =
Number.isFinite(measuredTextWidth) && measuredTextWidth !== undefined && measuredTextWidth > 0
? measuredTextWidth
: estimateTerminalTimestampTextWidth(normalizeTerminalTimestampFontSize(fontSize), label);
return Math.ceil(Math.max(
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
textWidth + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
));
};
export function TerminalTimestampGutter({
termRef,
containerRef,
enabled,
top,
sessionId,
color,
fontFamily,
fontSize,
fontWeight,
width,
onWidthChange,
}: TerminalTimestampGutterProps) {
const gutterRef = useRef<HTMLDivElement>(null);
const typography = getTerminalTimestampTypography({ fontFamily, fontSize, fontWeight });
useLayoutEffect(() => {
if (!enabled || !onWidthChange) return;
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
const measure = () => {
if (disposed) return;
const probe = document.createElement("span");
probe.textContent = TERMINAL_TIMESTAMP_SAMPLE_LABEL;
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.whiteSpace = "nowrap";
probe.style.fontFamily = typography.fontFamily;
probe.style.fontSize = `${typography.fontSize}px`;
probe.style.fontWeight = String(typography.fontWeight);
probe.style.fontVariantNumeric = "tabular-nums";
gutter.appendChild(probe);
const measuredTextWidth = probe.getBoundingClientRect().width;
probe.remove();
onWidthChange(resolveTerminalTimestampGutterWidth({
measuredTextWidth,
fontSize: typography.fontSize,
}));
};
measure();
const fonts = (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
void fonts?.ready?.then(measure);
return () => {
disposed = true;
};
}, [enabled, onWidthChange, sessionId, typography.fontFamily, typography.fontSize, typography.fontWeight]);
useEffect(() => {
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
let rafId: number | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let disposables: DisposableLike[] = [];
let resizeObserver: ResizeObserver | null = null;
const render = () => {
rafId = null;
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearElement(gutter);
return;
}
const screen = getTerminalScreen(container);
const rows = Math.max(1, term.rows || 1);
const cellHeight = screen.clientHeight / rows;
if (!Number.isFinite(cellHeight) || cellHeight <= 0) {
clearElement(gutter);
return;
}
const screenRect = screen.getBoundingClientRect();
const gutterRect = gutter.getBoundingClientRect();
const screenTop = screenRect.top - gutterRect.top;
const fragment = document.createDocumentFragment();
for (const { row, label } of getVisibleTerminalLineTimestampRows(term)) {
const item = document.createElement("div");
item.textContent = label;
item.className = "absolute left-0 right-0 px-2 text-right tabular-nums whitespace-nowrap";
item.style.top = `${screenTop + row * cellHeight}px`;
item.style.height = `${cellHeight}px`;
item.style.lineHeight = `${cellHeight}px`;
item.style.color = color;
item.style.fontFamily = typography.fontFamily;
item.style.fontSize = `${typography.fontSize}px`;
item.style.fontWeight = String(typography.fontWeight);
item.style.fontVariantNumeric = "tabular-nums";
fragment.appendChild(item);
}
clearElement(gutter);
gutter.appendChild(fragment);
};
const scheduleRender = () => {
if (disposed || rafId !== null) return;
if (typeof requestAnimationFrame === "function") {
rafId = requestAnimationFrame(render);
} else {
render();
}
};
const attach = () => {
if (disposed) return;
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearElement(gutter);
if (enabled) {
retryTimer = setTimeout(attach, 50);
}
return;
}
disposables = [
term.onScroll?.(scheduleRender),
term.onRender?.(scheduleRender),
term.onResize?.(scheduleRender),
].filter(Boolean) as DisposableLike[];
disposables.push({ dispose: onTerminalLineTimestampsChange(term, scheduleRender) });
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(scheduleRender);
resizeObserver.observe(container);
resizeObserver.observe(getTerminalScreen(container));
}
scheduleRender();
};
attach();
return () => {
disposed = true;
if (rafId !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(rafId);
}
if (retryTimer) {
clearTimeout(retryTimer);
}
for (const disposable of disposables) {
disposable.dispose();
}
resizeObserver?.disconnect();
clearElement(gutter);
};
}, [
color,
containerRef,
enabled,
sessionId,
termRef,
top,
typography.fontFamily,
typography.fontSize,
typography.fontWeight,
]);
if (!enabled) return null;
return (
<div
ref={gutterRef}
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 z-[1] overflow-hidden select-none border-r border-white/5 bg-black/10 text-[color:var(--terminal-ui-fg)]"
style={{
top,
width,
}}
data-section="terminal-timestamp-gutter"
/>
);
}

View File

@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
getLineTimestampToggleHostUpdate,
shouldShowLineTimestampToolbarToggle,
} from "./TerminalView.tsx";
test("line timestamp toggle creates a persistent host update", () => {
const host = {
id: "host-1",
label: "Host",
showLineTimestamps: false,
theme: "default",
};
assert.deepEqual(getLineTimestampToggleHostUpdate(host), {
id: "host-1",
showLineTimestamps: true,
});
assert.deepEqual(getLineTimestampToggleHostUpdate({ ...host, showLineTimestamps: true }), {
id: "host-1",
showLineTimestamps: false,
});
});
test("line timestamp toolbar toggle is hidden when timestamps are unavailable", () => {
assert.equal(shouldShowLineTimestampToolbarToggle(false, () => {}), false);
assert.equal(shouldShowLineTimestampToolbarToggle(true, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(undefined, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("popup terminals disable line timestamp controls", () => {
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(source, /lineTimestampsAvailable=\{false\}/);
});

View File

@@ -1,9 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import { TerminalServerStats } from './TerminalServerStats';
import {
TerminalTimestampGutter,
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
type TerminalViewContext = Record<string, any>;
type HostLineTimestampToggle = {
id: string;
showLineTimestamps?: boolean;
};
export function getLineTimestampToggleHostUpdate<T extends HostLineTimestampToggle>(
host: T,
): Pick<T, "id"> & { showLineTimestamps: boolean } {
return {
id: host.id,
showLineTimestamps: host.showLineTimestamps !== true,
};
}
export function shouldShowLineTimestampToolbarToggle(
lineTimestampsAvailable: boolean | undefined,
onUpdateHost: unknown,
): boolean {
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function shouldEnableYmodemAction({
isSerialConnection,
@@ -42,7 +67,24 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const terminalContentTop = isSearchOpen ? "64px" : "30px";
const showLineTimestampGutter = lineTimestampsAvailable !== false && host.showLineTimestamps === true;
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
));
useEffect(() => {
if (showLineTimestampGutter) return;
setLineTimestampGutterWidth(resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize }));
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, sessionId, showLineTimestampGutter]);
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -119,6 +161,34 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
statusDotTone,
)}
/>
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
showLineTimestampGutter ? "opacity-100" : "opacity-60 hover:opacity-100",
)}
style={
showLineTimestampGutter
? {
backgroundColor: 'var(--terminal-toolbar-btn-active)',
color: lineTimestampColor,
}
: undefined
}
onClick={() => onUpdateHost(getLineTimestampToggleHostUpdate(host))}
aria-label={lineTimestampToggleLabel}
aria-pressed={showLineTimestampGutter}
>
<Clock3 size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{lineTimestampToggleLabel}</TooltipContent>
</Tooltip>
)}
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<Tooltip>
<TooltipTrigger asChild>
@@ -237,11 +307,25 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
ref={containerRef}
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
top: terminalContentTop,
left: activeLineTimestampGutterWidth,
paddingLeft: 6,
backgroundColor: 'var(--terminal-ui-bg)',
}}
/>
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={showLineTimestampGutter}
top={terminalContentTop}
sessionId={sessionId}
color={lineTimestampColor}
fontFamily={resolvedFontFamily}
fontSize={effectiveFontSize}
fontWeight={effectiveFontWeight}
width={lineTimestampGutterWidth}
onWidthChange={handleLineTimestampGutterWidthChange}
/>
{hasSelection && selectionOverlayPosition && ctx.onAddSelectionToAI && handleAddSelectionToAI && (
<div
className="absolute z-30 pointer-events-none"

View File

@@ -613,7 +613,10 @@ test("local session runs startup command after attaching", async () => {
test("local session resets terminal timestamp state when reusing a terminal", async () => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let onData: ((data: string) => void) | null = null;
let cursorLine = 0;
const terminalBackend = {
backendAvailable: () => true,
@@ -681,8 +684,26 @@ test("local session resets terminal timestamp state when reusing a terminal", as
buffer: { active: { type: "normal" } },
write: (data: string, callback?: () => void) => {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker: (offset: number) => {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
writeln: noop,
scrollToBottom: noop,
};
@@ -694,9 +715,10 @@ test("local session resets terminal timestamp state when reusing a terminal", as
onData?.("fresh");
assert.equal(writes.length, 2);
assert.equal((writes[0].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.equal((writes[1].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.ok(writes[1].endsWith("] \x1b[22;39mfresh"));
assert.equal(writes[0], "unfinished");
assert.equal(writes[1], "fresh");
assert.deepEqual(markerLines, [0, 0]);
assert.deepEqual(disposedMarkerLines, [0]);
});
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {

View File

@@ -9,7 +9,7 @@ import {
closeOrphanBackendSession,
getFlowController,
isTerminalBootActive,
resetTerminalOutputTimestamps,
resetTerminalLineTimestampState,
tryAttachSessionToTerminal,
writeSessionData,
writeTerminalLine,
@@ -1079,7 +1079,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionRef.current = id;
getFlowController(ctx, term).reset();
resetTerminalOutputTimestamps(term);
resetTerminalLineTimestampState(term);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {

View File

@@ -0,0 +1,135 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createTerminalLineTimestampSegmenter,
formatTerminalLineTimestamp,
resolveTerminalTimestampGutterRows,
writeTerminalDataWithLineTimestamps,
} from "./terminalLineTimestamps.ts";
const createFakeTerm = () => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let cursorLine = 0;
const term = {
buffer: {
active: { type: "normal", viewportY: 0 },
},
rows: 24,
write(data: string, callback?: () => void) {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker(offset: number) {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
};
return { term, writes, markerLines, disposedMarkerLines };
};
test("segments terminal output into raw bytes plus timestamp markers", () => {
const segmenter = createTerminalLineTimestampSegmenter({
now: () => new Date(2026, 5, 6, 9, 8, 7),
});
assert.deepEqual(segmenter.append("hello\r\nnext"), [
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "hello\r\n" },
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "next" },
]);
});
test("does not create timestamp markers for alternate screen output", () => {
const segmenter = createTerminalLineTimestampSegmenter({
now: () => new Date(2026, 5, 6, 9, 8, 7),
});
assert.deepEqual(segmenter.append("\x1b[?1049hvim\r\ntext"), [
{ kind: "data", data: "\x1b[?1049hvim\r\ntext" },
]);
assert.deepEqual(segmenter.append("\x1b[?1049lprompt"), [
{ kind: "data", data: "\x1b[?1049l" },
{ kind: "timestamp", label: "09:08:07" },
{ kind: "data", data: "prompt" },
]);
});
test("resolves visible timestamp rows from marker lines", () => {
assert.deepEqual(
resolveTerminalTimestampGutterRows({
viewportY: 10,
rows: 4,
entries: [
{ marker: { line: 9 }, label: "before" },
{ marker: { line: 10 }, label: "10:00:00" },
{ marker: { line: 12 }, label: "10:00:02" },
{ marker: { line: 14 }, label: "after" },
],
}),
[
{ row: 0, label: "10:00:00" },
{ row: 2, label: "10:00:02" },
],
);
});
test("resolves timestamp rows for wrapped continuations", () => {
assert.deepEqual(
resolveTerminalTimestampGutterRows({
viewportY: 11,
rows: 4,
entries: [
{ marker: { line: 10 }, label: "10:00:10" },
{ marker: { line: 13 }, label: "10:00:13" },
],
isWrappedLine: (line) => line === 11 || line === 12,
}),
[
{ row: 0, label: "10:00:10" },
{ row: 1, label: "10:00:10" },
{ row: 2, label: "10:00:13" },
],
);
});
test("formats timestamp labels without terminal escape codes", () => {
assert.equal(formatTerminalLineTimestamp(new Date(2026, 5, 6, 1, 2, 3)), "01:02:03");
});
test("records line timestamps even while the gutter is hidden", () => {
const { term, writes, markerLines } = createFakeTerm();
writeTerminalDataWithLineTimestamps(term as never, "before\r\nnext", () => {});
assert.equal(writes.join(""), "before\r\nnext");
assert.deepEqual(markerLines, [0, 1]);
});
test("keeps recording and preserves existing timestamps when the gutter is hidden", () => {
const { term, markerLines, disposedMarkerLines } = createFakeTerm();
writeTerminalDataWithLineTimestamps(term as never, "shown\r\n", () => {});
writeTerminalDataWithLineTimestamps(term as never, "hidden\r\n", () => {});
writeTerminalDataWithLineTimestamps(term as never, "shown again", () => {});
assert.deepEqual(markerLines, [0, 1, 2]);
assert.deepEqual(disposedMarkerLines, []);
});

View File

@@ -0,0 +1,436 @@
import type { Terminal as XTerm } from "@xterm/xterm";
export type TerminalLineTimestampSegment =
| { kind: "data"; data: string }
| { kind: "timestamp"; label: string };
export type TerminalLineTimestampSegmenter = {
append: (data: string) => TerminalLineTimestampSegment[];
reset: () => void;
setAlternateScreenActive: (active: boolean) => void;
};
type TerminalLineTimestampSegmenterOptions = {
now?: () => Date;
};
type TimestampMarker = {
line: number;
isDisposed?: boolean;
dispose?: () => void;
onDispose?: (listener: () => void) => { dispose: () => void };
};
type TimestampEntry = {
marker: TimestampMarker;
label: string;
disposeListener?: { dispose: () => void };
};
type TimestampStore = {
segmenter: TerminalLineTimestampSegmenter;
entries: TimestampEntry[];
listeners: Set<() => void>;
};
export type TerminalTimestampGutterEntry = {
marker: { line: number; isDisposed?: boolean };
label: string;
};
export type TerminalTimestampGutterRow = {
row: number;
label: string;
};
const stores = new WeakMap<XTerm, TimestampStore>();
const pad2 = (value: number): string => value.toString().padStart(2, "0");
export const formatTerminalLineTimestamp = (date: Date): string => (
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
);
const isCsiFinalByte = (char: string): boolean => char >= "@" && char <= "~";
const readEscapeSequence = (
data: string,
startIndex: number,
): { sequence: string; endIndex: number; complete: boolean } | null => {
if (data[startIndex] !== "\x1b") return null;
const next = data[startIndex + 1];
if (!next) {
return { sequence: "\x1b", endIndex: startIndex, complete: false };
}
if (next === "[") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (isCsiFinalByte(data[index])) {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
if (next === "]") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\u0007") {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
};
}
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
if (next === "P" || next === "^" || next === "_" || next === "X") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
};
}
return {
sequence: data.slice(startIndex, startIndex + 2),
endIndex: startIndex + 1,
complete: true,
};
};
const getCsiFinal = (sequence: string): string | null => {
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
return sequence.at(-1) ?? null;
};
const getAlternateScreenAction = (sequence: string): "enter" | "leave" | null => {
const final = getCsiFinal(sequence);
if (final !== "h" && final !== "l") return null;
const params = sequence.slice(2, -1);
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => mode === 47 || mode === 1047 || mode === 1049)) {
return null;
}
return final === "h" ? "enter" : "leave";
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
};
const pushDataSegment = (
segments: TerminalLineTimestampSegment[],
data: string,
) => {
if (!data) return;
const previous = segments.at(-1);
if (previous?.kind === "data") {
previous.data += data;
return;
}
segments.push({ kind: "data", data });
};
export const createTerminalLineTimestampSegmenter = (
options: TerminalLineTimestampSegmenterOptions = {},
): TerminalLineTimestampSegmenter => {
const now = options.now ?? (() => new Date());
let atLineStart = true;
let currentLineStamped = false;
let pendingEscapeSequence = "";
let suspendedForAlternateScreen = false;
const resetLineState = () => {
atLineStart = true;
currentLineStamped = false;
};
const pushTimestampIfNeeded = (segments: TerminalLineTimestampSegment[]) => {
if (!atLineStart || currentLineStamped) return;
currentLineStamped = true;
atLineStart = false;
segments.push({
kind: "timestamp",
label: formatTerminalLineTimestamp(now()),
});
};
return {
append(data: string) {
const input = pendingEscapeSequence ? `${pendingEscapeSequence}${data}` : data;
pendingEscapeSequence = "";
const segments: TerminalLineTimestampSegment[] = [];
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (char === "\x1b") {
const sequence = readEscapeSequence(input, index);
if (sequence) {
if (!sequence.complete) {
pendingEscapeSequence = sequence.sequence;
break;
}
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
if (alternateScreenAction === "enter") {
pushDataSegment(segments, sequence.sequence);
suspendedForAlternateScreen = true;
resetLineState();
index = sequence.endIndex;
continue;
}
if (alternateScreenAction === "leave") {
pushDataSegment(segments, sequence.sequence);
suspendedForAlternateScreen = false;
resetLineState();
index = sequence.endIndex;
continue;
}
pushDataSegment(segments, sequence.sequence);
index = sequence.endIndex;
continue;
}
}
if (!suspendedForAlternateScreen && isPrintableOutput(char)) {
pushTimestampIfNeeded(segments);
}
pushDataSegment(segments, char);
if (suspendedForAlternateScreen) {
continue;
}
if (char === "\n") {
resetLineState();
} else if (char === "\r") {
atLineStart = true;
} else if (isPrintableOutput(char)) {
atLineStart = false;
}
}
return segments;
},
reset() {
resetLineState();
pendingEscapeSequence = "";
suspendedForAlternateScreen = false;
},
setAlternateScreenActive(active: boolean) {
suspendedForAlternateScreen = active;
if (active) {
resetLineState();
}
},
};
};
const notifyTimestampStore = (store: TimestampStore) => {
for (const listener of store.listeners) {
listener();
}
};
const getTimestampStore = (term: XTerm): TimestampStore => {
let store = stores.get(term);
if (!store) {
store = {
segmenter: createTerminalLineTimestampSegmenter(),
entries: [],
listeners: new Set(),
};
stores.set(term, store);
}
return store;
};
const pruneDisposedEntries = (store: TimestampStore) => {
store.entries = store.entries.filter((entry) => !entry.marker.isDisposed);
};
const resetTimestampStore = (store: TimestampStore) => {
for (const entry of store.entries) {
entry.disposeListener?.dispose();
entry.marker.dispose?.();
}
store.entries = [];
store.segmenter.reset();
notifyTimestampStore(store);
};
const recordTerminalLineTimestamp = (
term: XTerm,
store: TimestampStore,
label: string,
) => {
const registerMarker = (term as XTerm & { registerMarker?: (offset: number) => TimestampMarker | undefined }).registerMarker;
const marker = registerMarker?.call(term, 0);
if (!marker) return;
const entry: TimestampEntry = { marker, label };
entry.disposeListener = marker.onDispose?.(() => {
store.entries = store.entries.filter((candidate) => candidate !== entry);
entry.disposeListener?.dispose();
notifyTimestampStore(store);
});
store.entries.push(entry);
notifyTimestampStore(store);
};
export const resetTerminalLineTimestamps = (term: XTerm) => {
resetTimestampStore(getTimestampStore(term));
};
export const onTerminalLineTimestampsChange = (
term: XTerm,
listener: () => void,
) => {
const store = getTimestampStore(term);
store.listeners.add(listener);
return () => {
store.listeners.delete(listener);
};
};
export const resolveTerminalTimestampGutterRows = ({
viewportY,
rows,
entries,
isWrappedLine,
}: {
viewportY: number;
rows: number;
entries: readonly TerminalTimestampGutterEntry[];
isWrappedLine?: (line: number) => boolean;
}): TerminalTimestampGutterRow[] => {
const labelByLine = new Map<number, string>();
for (const entry of entries) {
if (entry.marker.isDisposed) continue;
labelByLine.set(entry.marker.line, entry.label);
}
const rowLabels = new Map<number, string>();
for (let row = 0; row < rows; row += 1) {
const line = viewportY + row;
const directLabel = labelByLine.get(line);
if (directLabel) {
rowLabels.set(row, directLabel);
continue;
}
if (!isWrappedLine?.(line)) continue;
let sourceLine = line;
while (sourceLine > 0 && isWrappedLine(sourceLine)) {
sourceLine -= 1;
}
const wrappedLabel = labelByLine.get(sourceLine);
if (wrappedLabel) {
rowLabels.set(row, wrappedLabel);
}
}
return [...rowLabels.entries()]
.sort(([a], [b]) => a - b)
.map(([row, label]) => ({ row, label }));
};
export const getVisibleTerminalLineTimestampRows = (
term: XTerm,
): TerminalTimestampGutterRow[] => {
if ((term.buffer.active as { type?: string }).type === "alternate") {
return [];
}
const store = getTimestampStore(term);
pruneDisposedEntries(store);
return resolveTerminalTimestampGutterRows({
viewportY: term.buffer.active.viewportY,
rows: term.rows,
entries: store.entries,
isWrappedLine: (line) => term.buffer.active.getLine(line)?.isWrapped === true,
});
};
export const writeTerminalDataWithLineTimestamps = (
term: XTerm,
data: string,
done: () => void,
) => {
const registerMarker = (term as XTerm & { registerMarker?: unknown }).registerMarker;
if (typeof registerMarker !== "function") {
term.write(data, done);
return;
}
const store = getTimestampStore(term);
store.segmenter.setAlternateScreenActive(
((term.buffer?.active as { type?: string } | undefined)?.type) === "alternate",
);
const segments = store.segmenter.append(data);
let index = 0;
const writeNext = () => {
const segment = segments[index];
index += 1;
if (!segment) {
done();
return;
}
if (segment.kind === "timestamp") {
recordTerminalLineTimestamp(term, store, segment.label);
writeNext();
return;
}
if (!segment.data) {
writeNext();
return;
}
term.write(segment.data, writeNext);
};
writeNext();
};

View File

@@ -1,128 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createTerminalOutputTimestampPrefixer,
formatTerminalOutputTimestamp,
} from "./terminalOutputTimestamps.ts";
test("formats terminal output timestamps as bracketed local time", () => {
assert.equal(
formatTerminalOutputTimestamp(new Date(2026, 5, 6, 9, 8, 7)),
"\x1b[2;90m[09:08:07] \x1b[22;39m",
);
});
test("prefixes each non-empty terminal output line across chunks", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 10, 11, 12),
});
assert.equal(prefixer.append("hello"), "\x1b[2;90m[10:11:12] \x1b[22;39mhello");
assert.equal(prefixer.append(" world\r\nnext"), " world\r\n\x1b[2;90m[10:11:12] \x1b[22;39mnext");
assert.equal(prefixer.append("\r\n"), "\r\n");
});
test("does not timestamp blank lines or repeated carriage-return updates", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 1, 2, 3),
});
assert.equal(
prefixer.append("\r\n\r\nprogress 1\rprogress 2\n"),
"\r\n\r\n\x1b[2;90m[01:02:03] \x1b[22;39mprogress 1\rprogress 2\n",
);
});
test("waits until printable output after leading terminal controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 4, 5, 6),
});
assert.equal(
prefixer.append("\x1b[?2004l\rpermission denied\r\n\x1b[01;32muser@host\x1b[00m$ "),
"\x1b[?2004l\r\x1b[2;90m[04:05:06] \x1b[22;39mpermission denied\r\n\x1b[01;32m\x1b[2;90m[04:05:06] \x1b[22;39m\x1b[1;32muser@host\x1b[00m$ ",
);
});
test("does not split timestamps into fragmented terminal controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 7, 8, 9),
});
assert.equal(prefixer.append("\x1b"), "");
assert.equal(
prefixer.append("[?2004l\rhello"),
"\x1b[?2004l\r\x1b[2;90m[07:08:09] \x1b[22;39mhello",
);
assert.equal(prefixer.append("\r\n\x1b[01;"), "\r\n");
assert.equal(
prefixer.append("32muser"),
"\x1b[01;32m\x1b[2;90m[07:08:09] \x1b[22;39m\x1b[1;32muser",
);
});
test("keeps long terminal control strings untouched across chunks", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 8, 9, 10),
});
assert.equal(prefixer.append("\x1bPtmux;payload"), "");
assert.equal(
prefixer.append("\x1b\\hello"),
"\x1bPtmux;payload\x1b\\\x1b[2;90m[08:09:10] \x1b[22;39mhello",
);
});
test("keeps alternate screen output untouched and resumes after exit", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 11, 12, 13),
});
assert.equal(
prefixer.append("\x1b[?1049hvim screen\r\nstill vim"),
"\x1b[?1049hvim screen\r\nstill vim",
);
assert.equal(
prefixer.append("\x1b[?1049lprompt"),
"\x1b[?1049l\x1b[2;90m[11:12:13] \x1b[22;39mprompt",
);
});
test("restores active text color after timestamp color", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 14, 15, 16),
});
assert.equal(
prefixer.append("\x1b[31mred\r\nnext"),
"\x1b[31m\x1b[2;90m[14:15:16] \x1b[22;39m\x1b[31mred\r\n\x1b[2;90m[14:15:16] \x1b[22;39m\x1b[31mnext",
);
});
test("does not timestamp output that has no printable text", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 17, 18, 19),
});
assert.equal(prefixer.append("\x07\b\x1b[0m\r\n"), "\x07\b\x1b[0m\r\n");
assert.equal(prefixer.append("visible"), "\x1b[2;90m[17:18:19] \x1b[22;39mvisible");
});
test("timestamps printable text after leading invisible controls", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 20, 21, 22),
});
assert.equal(prefixer.append("\x07visible"), "\x07\x1b[2;90m[20:21:22] \x1b[22;39mvisible");
prefixer.reset();
assert.equal(prefixer.append("\bvisible"), "\b\x1b[2;90m[20:21:22] \x1b[22;39mvisible");
});
test("timestamps before a leading tab", () => {
const prefixer = createTerminalOutputTimestampPrefixer({
now: () => new Date(2026, 5, 6, 23, 24, 25),
});
assert.equal(prefixer.append("\tvisible"), "\x1b[2;90m[23:24:25] \x1b[22;39m\tvisible");
});

View File

@@ -1,322 +0,0 @@
export type TerminalOutputTimestampPrefixer = {
append: (data: string) => string;
reset: () => void;
setAlternateScreenActive: (active: boolean) => void;
};
type TerminalOutputTimestampPrefixerOptions = {
now?: () => Date;
};
const pad2 = (value: number): string => value.toString().padStart(2, "0");
export const formatTerminalOutputTimestamp = (date: Date, restoreSequence = ""): string => (
`\x1b[2;90m[${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}] \x1b[22;39m${restoreSequence}`
);
const isCsiFinalByte = (char: string): boolean => char >= "@" && char <= "~";
const readEscapeSequence = (
data: string,
startIndex: number,
): { sequence: string; endIndex: number; complete: boolean; isColorSequence: boolean } | null => {
if (data[startIndex] !== "\x1b") return null;
const next = data[startIndex + 1];
if (!next) {
return { sequence: "\x1b", endIndex: startIndex, complete: false, isColorSequence: false };
}
if (next === "[") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (isCsiFinalByte(data[index])) {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
isColorSequence: data[index] === "m",
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
if (next === "]") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\u0007") {
return {
sequence: data.slice(startIndex, index + 1),
endIndex: index,
complete: true,
isColorSequence: false,
};
}
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
isColorSequence: false,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
if (next === "P" || next === "^" || next === "_" || next === "X") {
for (let index = startIndex + 2; index < data.length; index += 1) {
if (data[index] === "\x1b" && data[index + 1] === "\\") {
return {
sequence: data.slice(startIndex, index + 2),
endIndex: index + 1,
complete: true,
isColorSequence: false,
};
}
}
return {
sequence: data.slice(startIndex),
endIndex: data.length - 1,
complete: false,
isColorSequence: false,
};
}
return {
sequence: data.slice(startIndex, startIndex + 2),
endIndex: startIndex + 1,
complete: true,
isColorSequence: false,
};
};
const getCsiFinal = (sequence: string): string | null => {
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
return sequence.at(-1) ?? null;
};
const getCsiParams = (sequence: string): string => sequence.slice(2, -1);
const getAlternateScreenAction = (sequence: string): "enter" | "leave" | null => {
const final = getCsiFinal(sequence);
if (final !== "h" && final !== "l") return null;
const params = getCsiParams(sequence);
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => mode === 47 || mode === 1047 || mode === 1049)) {
return null;
}
return final === "h" ? "enter" : "leave";
};
const parseSgrParams = (sequence: string): number[] => {
if (getCsiFinal(sequence) !== "m") return [];
const params = getCsiParams(sequence);
if (params === "") return [0];
return params.split(";").map((part) => {
if (part === "") return 0;
const value = Number.parseInt(part, 10);
return Number.isFinite(value) ? value : 0;
});
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
};
export const createTerminalOutputTimestampPrefixer = (
options: TerminalOutputTimestampPrefixerOptions = {},
): TerminalOutputTimestampPrefixer => {
const now = options.now ?? (() => new Date());
let atLineStart = true;
let currentLinePrefixed = false;
let pendingEscapeSequence = "";
let suspendedForAlternateScreen = false;
const activeStyleFlags = new Set<number>();
let activeForeground: number[] = [];
let activeBackground: number[] = [];
const restoreActiveSgr = () => {
const orderedFlags = [1, 2, 3, 4, 7, 9].filter((code) => activeStyleFlags.has(code));
const codes = [...orderedFlags, ...activeForeground, ...activeBackground];
return codes.length ? `\x1b[${codes.join(";")}m` : "";
};
const setFlag = (code: number) => {
activeStyleFlags.add(code);
};
const applySgrSequence = (sequence: string) => {
const codes = parseSgrParams(sequence);
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index] ?? 0;
if (code === 0) {
activeStyleFlags.clear();
activeForeground = [];
activeBackground = [];
} else if (code === 1 || code === 2 || code === 3 || code === 4 || code === 7 || code === 9) {
setFlag(code);
} else if (code === 21) {
activeStyleFlags.delete(1);
} else if (code === 22) {
activeStyleFlags.delete(1);
activeStyleFlags.delete(2);
} else if (code === 23) {
activeStyleFlags.delete(3);
} else if (code === 24) {
activeStyleFlags.delete(4);
} else if (code === 27) {
activeStyleFlags.delete(7);
} else if (code === 29) {
activeStyleFlags.delete(9);
} else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
activeForeground = [code];
} else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
activeBackground = [code];
} else if (code === 39) {
activeForeground = [];
} else if (code === 49) {
activeBackground = [];
} else if (code === 38 && codes[index + 1] === 5 && codes[index + 2] !== undefined) {
activeForeground = [38, 5, codes[index + 2]];
index += 2;
} else if (
code === 38 &&
codes[index + 1] === 2 &&
codes[index + 2] !== undefined &&
codes[index + 3] !== undefined &&
codes[index + 4] !== undefined
) {
activeForeground = [38, 2, codes[index + 2], codes[index + 3], codes[index + 4]];
index += 4;
} else if (code === 48 && codes[index + 1] === 5 && codes[index + 2] !== undefined) {
activeBackground = [48, 5, codes[index + 2]];
index += 2;
} else if (
code === 48 &&
codes[index + 1] === 2 &&
codes[index + 2] !== undefined &&
codes[index + 3] !== undefined &&
codes[index + 4] !== undefined
) {
activeBackground = [48, 2, codes[index + 2], codes[index + 3], codes[index + 4]];
index += 4;
}
}
};
const prefixIfNeeded = () => {
if (!atLineStart || currentLinePrefixed) return "";
currentLinePrefixed = true;
atLineStart = false;
return formatTerminalOutputTimestamp(now(), restoreActiveSgr());
};
const resetLineState = () => {
atLineStart = true;
currentLinePrefixed = false;
};
return {
append(data: string) {
const input = pendingEscapeSequence ? `${pendingEscapeSequence}${data}` : data;
pendingEscapeSequence = "";
let output = "";
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (char === "\x1b") {
const sequence = readEscapeSequence(input, index);
if (sequence) {
if (!sequence.complete) {
pendingEscapeSequence = sequence.sequence;
break;
}
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
if (alternateScreenAction === "enter") {
output += sequence.sequence;
suspendedForAlternateScreen = true;
resetLineState();
index = sequence.endIndex;
continue;
}
if (alternateScreenAction === "leave") {
output += sequence.sequence;
suspendedForAlternateScreen = false;
resetLineState();
index = sequence.endIndex;
continue;
}
if (suspendedForAlternateScreen) {
output += sequence.sequence;
index = sequence.endIndex;
continue;
}
if (sequence.isColorSequence) {
output += sequence.sequence;
applySgrSequence(sequence.sequence);
} else {
output += sequence.sequence;
}
index = sequence.endIndex;
continue;
}
}
if (suspendedForAlternateScreen) {
output += char;
continue;
}
if (isPrintableOutput(char)) {
output += prefixIfNeeded();
}
output += char;
if (char === "\n") {
resetLineState();
} else if (char === "\r") {
atLineStart = true;
} else if (isPrintableOutput(char)) {
atLineStart = false;
}
}
return output;
},
reset() {
resetLineState();
pendingEscapeSequence = "";
suspendedForAlternateScreen = false;
activeStyleFlags.clear();
activeForeground = [];
activeBackground = [];
},
setAlternateScreenActive(active: boolean) {
suspendedForAlternateScreen = active;
if (active) {
resetLineState();
}
},
};
};

View File

@@ -10,18 +10,39 @@ import {
const createFakeTerm = (activeType = "normal") => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let cursorLine = 0;
const term = {
buffer: {
active: { type: activeType },
},
write(data: string, callback?: () => void) {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker(offset: number) {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
scrollToBottom() {},
} as unknown as XTerm;
return { term, writes };
return { term, writes, markerLines, disposedMarkerLines };
};
const createContext = (showLineTimestamps: boolean, host: Record<string, unknown> = {}) => ({
@@ -43,53 +64,68 @@ const createContext = (showLineTimestamps: boolean, host: Record<string, unknown
promptLineBreakStateRef: { current: undefined },
});
test("writeSessionData prefixes terminal output lines when enabled", () => {
const { term, writes } = createFakeTerm();
test("writeSessionData records terminal output timestamps without changing output bytes", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "hello\r\nnext");
assert.equal(writes.length, 1);
assert.equal((writes[0].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 2);
assert.ok(writes[0].includes("\x1b[2;90m["));
assert.ok(writes[0].includes("] \x1b[22;39mhello\r\n\x1b[2;90m["));
assert.ok(writes[0].endsWith("] \x1b[22;39mnext"));
assert.equal(writes.join(""), "hello\r\nnext");
assert.equal((writes.join("").match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 0);
assert.deepEqual(markerLines, [0, 1]);
});
test("writeSessionData does not use the global timestamp setting", () => {
const { term, writes } = createFakeTerm();
test("writeSessionData records timestamps independently of display settings", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(true, { showLineTimestamps: false }) as never, term, "hello");
assert.deepEqual(writes, ["hello"]);
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData only prefixes timestamps for hosts with timestamps enabled", () => {
const { term, writes } = createFakeTerm();
test("writeSessionData records timestamps for hosts with timestamps enabled", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "hello");
assert.equal(writes.length, 1);
assert.equal((writes[0].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.equal(writes.join(""), "hello");
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData skips timestamps on the alternate screen", () => {
const { term, writes } = createFakeTerm("alternate");
const { term, writes, markerLines } = createFakeTerm("alternate");
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "vim screen");
assert.deepEqual(writes, ["vim screen"]);
assert.deepEqual(markerLines, []);
});
test("writeSessionData does not timestamp output that enters alternate screen in the same chunk", () => {
const { term, writes } = createFakeTerm();
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "\x1b[?1049hvim screen");
assert.deepEqual(writes, ["\x1b[?1049hvim screen"]);
assert.deepEqual(markerLines, []);
});
test("writeSessionData resumes timestamps after leaving alternate screen in the same chunk", () => {
const { term, writes } = createFakeTerm("alternate");
const { term, writes, markerLines } = createFakeTerm("alternate");
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "\x1b[?1049lprompt");
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x1b[?1049l\x1b[2;90m["));
assert.ok(writes[0].endsWith("] \x1b[22;39mprompt"));
assert.equal(writes.join(""), "\x1b[?1049lprompt");
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData keeps recording while the latest host display setting changes", () => {
const { term, writes, markerLines, disposedMarkerLines } = createFakeTerm();
const ctx = createContext(false, { showLineTimestamps: false });
writeSessionData(ctx as never, term, "before\r\n");
ctx.host = { showLineTimestamps: true };
writeSessionData(ctx as never, term, "enabled\r\n");
ctx.host = { showLineTimestamps: false };
writeSessionData(ctx as never, term, "disabled");
assert.equal(writes.join(""), "before\r\nenabled\r\ndisabled");
assert.deepEqual(markerLines, [0, 1, 2]);
assert.deepEqual(disposedMarkerLines, []);
});
test("attachSessionToTerminal resets timestamp state for a reused terminal", () => {
@@ -119,8 +155,7 @@ test("attachSessionToTerminal resets timestamp state for a reused terminal", ()
writeSessionData(ctx as never, term, "fresh");
assert.equal(writes.length, 2);
assert.equal((writes[1].match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 1);
assert.ok(writes[1].endsWith("] \x1b[22;39mfresh"));
assert.equal(writes[1], "fresh");
});
test("attachSessionToTerminal hints for sudo password prompts and fills on confirm", () => {

View File

@@ -14,9 +14,9 @@ import { createOutputFlowController, type OutputFlowController } from "./outputF
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
import { clearConnectionToken } from "./terminalDistroDetection";
import {
createTerminalOutputTimestampPrefixer,
type TerminalOutputTimestampPrefixer,
} from "./terminalOutputTimestamps";
resetTerminalLineTimestamps,
writeTerminalDataWithLineTimestamps,
} from "./terminalLineTimestamps";
import { createSudoPasswordAutofill } from "./terminalSudoAutofill";
export const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
@@ -58,7 +58,6 @@ type TerminalWriteQueue = {
};
const terminalWriteQueues = new WeakMap<XTerm, TerminalWriteQueue>();
const terminalOutputTimestampPrefixers = new WeakMap<XTerm, TerminalOutputTimestampPrefixer>();
const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
const next = queue.pending.shift();
@@ -100,33 +99,6 @@ const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
const getTerminalOutputTimestampPrefixer = (term: XTerm): TerminalOutputTimestampPrefixer => {
let prefixer = terminalOutputTimestampPrefixers.get(term);
if (!prefixer) {
prefixer = createTerminalOutputTimestampPrefixer();
terminalOutputTimestampPrefixers.set(term, prefixer);
}
return prefixer;
};
export const resetTerminalOutputTimestamps = (term: XTerm) => {
getTerminalOutputTimestampPrefixer(term).reset();
};
const applyTerminalOutputTimestamps = (
term: XTerm,
data: string,
enabled: boolean,
): string => {
const prefixer = getTerminalOutputTimestampPrefixer(term);
if (!enabled) {
prefixer.reset();
return data;
}
prefixer.setAlternateScreenActive((term.buffer.active as { type?: string }).type === "alternate");
return prefixer.append(data);
};
export const getFlowController = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -150,6 +122,8 @@ export const getFlowController = (
return controller;
};
export const resetTerminalLineTimestampState = resetTerminalLineTimestamps;
export const writeTerminalLine = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -171,7 +145,6 @@ export const writeSessionData = (
flow.received(data.length);
enqueueTerminalWrite(term, (done) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const timestampsEnabled = ctx.host?.showLineTimestamps === true;
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) {
ctx.promptLineBreakStateRef.current.pendingCommand = false;
@@ -184,7 +157,6 @@ export const writeSessionData = (
ctx.promptLineBreakStateRef?.current,
forcePromptNewLine,
);
const finalDisplayData = applyTerminalOutputTimestamps(term, displayData, timestampsEnabled);
ctx.onTerminalLogData?.(pasteDisplayData);
const clearPasteResidualAndCapture = () => {
const cleanupData = clearPasteResidualAfterTerminalWrite(term);
@@ -208,7 +180,7 @@ export const writeSessionData = (
flow.written(data.length);
};
term.write(finalDisplayData, afterWrite);
writeTerminalDataWithLineTimestamps(term, displayData, afterWrite);
});
};
@@ -267,7 +239,7 @@ export const attachSessionToTerminal = (
ctx.sessionRef.current = id;
// Clear any stale back-pressure accounting from a prior session on this term.
getFlowController(ctx, term).reset();
resetTerminalOutputTimestamps(term);
resetTerminalLineTimestamps(term);
ctx.onSessionAttached?.(id);
const sudoAutofill = createSudoPasswordAutofill({
password: opts?.sudoAutofillPassword,

View File

@@ -91,6 +91,8 @@ export interface TerminalProps {
snippetPackages?: string[];
/** Minimal toolbar for popup terminals (compose, search, snippets only). */
compactToolbar?: boolean;
/** Line timestamps are unavailable in popup terminals that stream shell output without timestamp metadata. */
lineTimestampsAvailable?: boolean;
chainHosts?: Host[];
themePreviewId?: string;
knownHosts?: KnownHost[];

View File

@@ -12,6 +12,7 @@ export const terminalPropsAreEqual = (
&& prev.snippets === next.snippets
&& prev.snippetPackages === next.snippetPackages
&& prev.compactToolbar === next.compactToolbar
&& prev.lineTimestampsAvailable === next.lineTimestampsAvailable
&& prev.chainHosts === next.chainHosts
&& getThemePreviewId(prev) === getThemePreviewId(next)
&& prev.knownHosts === next.knownHosts

View File

@@ -16,6 +16,7 @@ Object.defineProperty(globalThis, 'localStorage', {
const {
applyTerminalHostTreeHostRename,
shouldShowTerminalHostHoverCard,
getTerminalHostTreeHiddenSurfaceShellWidth,
getTerminalHostTreeInitialLayoutWidth,
getTerminalHostTreeLayoutTargetWidth,
getTerminalHostTreeMeasuredLayoutWidth,
@@ -55,6 +56,12 @@ test('host tree layout target follows visible surface state', () => {
assert.equal(getTerminalHostTreeLayoutTargetWidth(false, 240), 0);
});
test('host tree hidden surface shell keeps the open width for return navigation', () => {
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(true, true, 240), 240);
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(false, true, 240), 0);
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(true, false, 240), 0);
});
test('host tree layout starts collapsed so first mount can animate open', () => {
assert.equal(getTerminalHostTreeInitialLayoutWidth(), 0);
});
@@ -77,11 +84,13 @@ test('host tree layout width follows the animated shell via ResizeObserver', ()
assert.doesNotMatch(source, /performance\.now\(\)/);
});
test('host tree collapses instantly when hidden behind root pages', () => {
test('host tree keeps shell width while hidden behind root pages', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /isResizing \|\| !surfaceVisible/);
assert.match(source, /if \(!surfaceVisible\) \{\s*setShellWidth\(0\);\s*terminalHostTreeStore\.setLayoutWidth\(0\);/);
assert.match(source, /const hiddenSurfaceShellWidth = getTerminalHostTreeHiddenSurfaceShellWidth/);
assert.match(source, /if \(!surfaceVisible\) \{\s*setShellWidth\(hiddenSurfaceShellWidth\);\s*terminalHostTreeStore\.setLayoutWidth\(0\);/);
assert.doesNotMatch(source, /if \(!surfaceVisible\) \{\s*setShellWidth\(0\);/);
});
test('host tree sidebar memo tracks surface visibility changes', () => {

View File

@@ -164,6 +164,17 @@ export function getTerminalHostTreeLayoutTargetWidth(isVisible: boolean, display
return isVisible ? displayWidth : 0;
}
export function getTerminalHostTreeHiddenSurfaceShellWidth(
isOpen: boolean,
enabled: boolean,
displayWidth: number,
): number {
return getTerminalHostTreeLayoutTargetWidth(
isTerminalHostTreeSidebarVisible(isOpen, enabled, true),
displayWidth,
);
}
export function getTerminalHostTreeInitialLayoutWidth(): number {
return 0;
}
@@ -945,6 +956,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
const displayWidth = resizePreviewWidth ?? sidebarWidth;
const targetLayoutWidth = getTerminalHostTreeLayoutTargetWidth(isVisible, displayWidth);
const hiddenSurfaceShellWidth = getTerminalHostTreeHiddenSurfaceShellWidth(isOpen, enabled, displayWidth);
const [shellWidth, setShellWidth] = useState(getTerminalHostTreeInitialLayoutWidth);
const cancelSyncLayoutWidthRef = useRef<(() => void) | null>(null);
const prevIsVisibleRef = useRef(isVisible);
@@ -1023,7 +1035,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}
if (!surfaceVisible) {
setShellWidth(0);
setShellWidth(hiddenSurfaceShellWidth);
terminalHostTreeStore.setLayoutWidth(0);
return;
}
@@ -1038,7 +1050,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
cancelSyncLayoutWidthRef.current?.();
cancelSyncLayoutWidthRef.current = null;
};
}, [isResizing, surfaceVisible, syncLayoutWidthFromShell, targetLayoutWidth]);
}, [hiddenSurfaceShellWidth, isResizing, surfaceVisible, syncLayoutWidthFromShell, targetLayoutWidth]);
return (
<div

View File

@@ -484,7 +484,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
activeTerminalCwd={activeTerminalCwd}
activeTerminalCwd={isVisibleSftpPanel && sftpFollowTerminalCwd ? activeTerminalCwd : null}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
onSftpFollowTerminalCwdChange={setSftpFollowTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}

View File

@@ -22,6 +22,8 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
sidePanelWidth,
});
const activityEscapeFiltersRef = useRef<any>(new Map());
const remeasureWorkspaceArea = useCallback(() => {
const el = workspaceInnerRef.current;
if (!el) return;
@@ -219,11 +221,22 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}, [activeTabId, sessions]);
useEffect(() => {
const activeSessionIds = new Set(activityTrackedSessions.map((session) => session.id));
for (const sessionId of activityEscapeFiltersRef.current.keys()) {
if (!activeSessionIds.has(sessionId)) {
activityEscapeFiltersRef.current.delete(sessionId);
}
}
const unsubscribers = activityTrackedSessions.map((session) => {
const filter = new ChunkedEscapeFilter();
let filter = activityEscapeFiltersRef.current.get(session.id);
if (!filter) {
filter = new ChunkedEscapeFilter();
activityEscapeFiltersRef.current.set(session.id, filter);
}
return onSessionData(session.id, (chunk) => {
if (!hasNotifiableTerminalOutput(filter, chunk)) return;
if (!shouldMarkSessionActivity(activeTabIdRef.current, session)) {
return;
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
import { VaultHostListSection } from "./VaultHostListSection";
import {
VaultHeaderSearch,
@@ -701,7 +702,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
groupConfigs={groupConfigs}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
const latestHost = hosts.find((entry: { id: string }) => entry.id === host.id);
const nextHost = preserveConcurrentHostLineTimestampUpdate({
draft: host,
openedHost: editingHost,
latestHost,
});
onUpdateHosts(upsertHostById(hosts, nextHost));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);

View File

@@ -7,6 +7,7 @@ import {
migrateHostsFromLegacyLineTimestamps,
normalizeDistroId,
normalizePrimaryTelnetState,
preserveConcurrentHostLineTimestampUpdate,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
@@ -135,6 +136,28 @@ test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", ()
]);
});
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: true });
const draft = makeHost({ label: "Edited label", showLineTimestamps: false });
assert.deepEqual(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }),
{ ...draft, showLineTimestamps: true },
);
});
test("keeps host details timestamp value when the details form edits it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: false });
const draft = makeHost({ showLineTimestamps: true });
assert.equal(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }).showLineTimestamps,
true,
);
});
test("normalizePrimaryTelnetState preserves an explicit telnet port", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "telnet",

View File

@@ -257,6 +257,22 @@ export const migrateHostsFromLegacyLineTimestamps = (
return changed ? migrated : hosts;
};
export const preserveConcurrentHostLineTimestampUpdate = ({
draft,
openedHost,
latestHost,
}: {
draft: Host;
openedHost?: Host | null;
latestHost?: Host | null;
}): Host => {
if (!openedHost || !latestHost) return draft;
if (draft.id !== openedHost.id || draft.id !== latestHost.id) return draft;
if (draft.showLineTimestamps !== openedHost.showLineTimestamps) return draft;
if (latestHost.showLineTimestamps === openedHost.showLineTimestamps) return draft;
return { ...draft, showLineTimestamps: latestHost.showLineTimestamps };
};
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists

View File

@@ -168,9 +168,8 @@ export interface Host {
keepaliveInterval?: number; // Seconds; 0 = disabled
keepaliveCountMax?: number; // Unanswered keepalives before declaring dead
keepaliveOverride?: boolean;
// Prefix visible terminal output for this host with local timestamps.
// Kept per-host because some shells/prompts (notably PowerShell + oh-my-posh)
// break when extra printable content is injected into the terminal stream.
// Show local timestamps for this host beside terminal output rows.
// Kept per-host because timestamp visibility is usually a host/workflow preference.
showLineTimestamps?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';

View File

@@ -112,7 +112,7 @@ export interface TerminalSettings {
// Rendering
rendererType: 'auto' | 'webgl' | 'dom'; // Terminal renderer: auto (detect based on hardware), webgl, or dom
showLineTimestamps: boolean; // Prefix terminal output lines with timestamps before rendering
showLineTimestamps: boolean; // Show output timestamps in a side gutter
// Autocomplete
autocompleteEnabled: boolean; // Enable terminal command autocomplete
@@ -289,7 +289,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
showLineTimestamps: false, // Opt-in: prefixes terminal output data before rendering
showLineTimestamps: false, // Opt-in: shows output timestamps beside terminal lines
autocompleteEnabled: true, // Autocomplete enabled by default
autocompleteGhostText: false, // Mutually exclusive with popup menu
autocompletePopupMenu: true, // Popup menu enabled by default

View File

@@ -129,6 +129,25 @@ test("terminal updates still persist SFTP bookmarks", () => {
]);
});
test("partial terminal updates preserve unrelated saved host fields", () => {
const hostWithAppearance: Host = {
...savedHost,
fontSize: undefined,
fontSizeOverride: false,
showLineTimestamps: false,
};
const merged = mergeTerminalHostUpdate(hostWithAppearance, {
id: hostWithAppearance.id,
showLineTimestamps: true,
});
assert.equal(merged.showLineTimestamps, true);
assert.equal(merged.fontSize, undefined);
assert.equal(merged.fontSizeOverride, false);
assert.equal(merged.hostname, hostWithAppearance.hostname);
});
test("applySessionFontSizeToHost overlays workspace pane font size", () => {
const host: Host = {
id: "host-1",

View File

@@ -1,5 +1,7 @@
import { Host, TerminalSession, TerminalTheme } from './models';
export type TerminalHostUpdate = Pick<Host, 'id'> & Partial<Host>;
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -40,9 +42,10 @@ export const clearHostFontSizeOverride = (host: Host): Host => ({
export const mergeTerminalHostUpdate = (
savedHost: Host,
terminalHostUpdate: Host,
terminalHostUpdate: TerminalHostUpdate,
): Host => {
const nextHost: Host = {
...savedHost,
...terminalHostUpdate,
id: savedHost.id,
protocol: savedHost.protocol,

View File

@@ -8,6 +8,7 @@ module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
electronLanguages: ['en', 'en-US', 'zh_CN', 'zh-CN', 'ru'],
// Give the macOS build a unique Mach-O LC_UUID before signing, so macOS
// Local Network privacy treats Netcatty distinctly from every other
// Electron app (which all share Electron's prebuilt LC_UUID) — see #1040
@@ -43,8 +44,43 @@ module.exports = {
'lib/**/*.json',
'!electron/.dev-config.json',
'skills/**/*',
'public/**/*',
'node_modules/**/*',
'!public/**/*',
'!**/*.map',
'!**/*.d.ts',
'!**/*.d.mts',
'!**/*.d.cts',
'!**/*.ts',
'!**/*.tsx',
'!**/*.test.*',
'!**/*.spec.*',
'!**/__tests__/**/*',
'!**/test/**/*',
'!**/tests/**/*',
'!**/example/**/*',
'!**/examples/**/*',
// Renderer-only packages are compiled into dist by Vite. Keep them
// installed for npm run dev/build, but do not ship the duplicate source
// packages in release artifacts.
'!node_modules/@fontsource/**/*',
'!node_modules/@monaco-editor/**/*',
'!node_modules/@radix-ui/**/*',
'!node_modules/@xterm/**/*',
'!node_modules/lucide-react/**/*',
'!node_modules/monaco-editor/**/*',
'!node_modules/react/**/*',
'!node_modules/react-dom/**/*',
// Heavy cloud completion specs are intentionally not bundled. The main
// process filters the same prefixes so dev and packaged builds behave
// consistently.
'!node_modules/@withfig/autocomplete/build/aws.js',
'!node_modules/@withfig/autocomplete/build/aws/**/*',
'!node_modules/@withfig/autocomplete/build/gcloud.js',
'!node_modules/@withfig/autocomplete/build/gcloud/**/*',
'!node_modules/@withfig/autocomplete/build/az/**/*',
// Fig specs are already compiled JavaScript; TypeScript is only pulled
// in by Fig helper packages as build tooling and is not needed at app
// runtime.
'!node_modules/typescript/**/*',
// ── Exclude per-platform native agent binaries (~100s of MB each). ──
// Netcatty is "bring your own CLI": each SDK is pointed at the user's
// system-installed CLI via an absolute path override (claude
@@ -62,7 +98,12 @@ module.exports = {
'!node_modules/@anthropic-ai/claude-code-*/**/*',
'!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*',
'!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*',
'!node_modules/@github/copilot/**/*'
'!node_modules/@github/copilot/**/*',
// CodeBuddy follows the same first-party integration model as the
// other coding agents: Netcatty discovers and passes the user's
// installed CLI path to the SDK. Keep the small SDK wrapper, but do not
// bundle the full CodeBuddy CLI payload (rg vendors + web UI).
'!node_modules/@tencent-ai/agent-sdk/cli/**/*'
],
asarUnpack: [
'node_modules/node-pty/**/*',

View File

@@ -6,7 +6,7 @@
*/
"use strict";
const { execFileSync } = require("node:child_process");
const { execFile, execFileSync } = require("node:child_process");
const { existsSync, readFileSync, statSync } = require("node:fs");
const path = require("node:path");
@@ -18,6 +18,20 @@ const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
const MAX_PROMPT_TRACK_TAIL = 4096;
function execFileAsync(command, args, options) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
error.stdout = stdout;
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
}
// ── ANSI stripping ──
function stripAnsi(input) {
@@ -444,6 +458,19 @@ function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
return raw;
}
async function resolveSdkBinPathAsync(command, shellEnv, platform = process.platform) {
const raw = await resolveCliFromPathAsync(command, shellEnv);
if (!raw) return null;
if (platform !== "win32") return raw;
if (command === "codex") {
return resolveCodexExecutableForSdk(raw, platform);
}
if (command === "claude") {
return resolveClaudeCodeExecutableForSdk(raw, platform);
}
return raw;
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
@@ -470,6 +497,32 @@ function resolveCliFromPath(command, shellEnv) {
return null;
}
async function resolveCliFromPathAsync(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
return null;
}
if (shellEnv) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const { stdout } = await execFileAsync(whichCmd, [command], {
encoding: "utf8",
timeout: 3000,
env: shellEnv,
});
const resolved = String(stdout || "").trim();
for (const candidate of resolved.split(/\r?\n/)) {
const normalized = normalizeCliPathForPlatform(candidate);
if (normalized) return normalized;
}
} catch {
// Not found on PATH
}
}
return null;
}
function toUnpackedAsarPath(filePath) {
const unpackedPath = filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
if (unpackedPath !== filePath && existsSync(unpackedPath)) {
@@ -490,6 +543,8 @@ function isPlausibleCliVersionOutput(value) {
// ── Shell environment (cached) ──
let _cachedShellEnv = null;
let _shellEnvPromise = null;
let _shellEnvGeneration = 0;
/**
* Run the user's login shell once to print its PATH. Used as a fallback when
@@ -508,6 +563,19 @@ function defaultRunLoginShellPath() {
});
}
async function defaultRunLoginShellPathAsync() {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const { stdout } = await execFileAsync(shell, ["-ilc", 'echo -n "$PATH"'], {
encoding: "utf8",
timeout: 4000,
env: { ...process.env, HOME: process.env.HOME || "" },
});
return stdout;
}
/**
* Union a login-shell PATH ahead of basePath and de-duplicate, so a GUI launch
* (Finder/Dock) with a stripped PATH still discovers user-installed CLIs.
@@ -538,61 +606,89 @@ function mergeLoginShellPath({
async function getShellEnv() {
if (_cachedShellEnv) return _cachedShellEnv;
if (_shellEnvPromise) return _shellEnvPromise;
const home = process.env.HOME || "";
const extraPaths = [
`${home}/.local/bin`,
`${home}/.npm-global/bin`,
"/usr/local/bin",
"/opt/homebrew/bin",
];
const generation = _shellEnvGeneration;
_shellEnvPromise = (async () => {
const home = process.env.HOME || "";
const extraPaths = [
`${home}/.local/bin`,
`${home}/.npm-global/bin`,
"/usr/local/bin",
"/opt/homebrew/bin",
];
if (process.platform === "win32") {
_cachedShellEnv = {
...process.env,
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
};
return _cachedShellEnv;
}
// On macOS/Linux, spawn a login shell to capture the real environment.
try {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const envOutput = execFileSync(shell, ['-ilc', 'env'], {
encoding: "utf8",
timeout: 10000,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, HOME: home },
});
const envMap = {};
for (const line of envOutput.split("\n")) {
const idx = line.indexOf("=");
if (idx > 0) {
envMap[line.slice(0, idx)] = line.slice(idx + 1);
if (process.platform === "win32") {
const nextEnv = {
...process.env,
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
}
const shellPath = envMap.PATH || "";
const mergedPath = [...extraPaths, shellPath, process.env.PATH || ""].join(path.delimiter);
// Layer-0 fix-path: front-load + de-duplicate the login-shell PATH we just
// captured (reuse the `-ilc env` result above — no second shell spawn).
_cachedShellEnv = {
...envMap,
...process.env,
PATH: mergeLoginShellPath({ basePath: mergedPath, runLoginShellPath: () => shellPath }),
};
} catch {
// `-ilc env` failed — try a lighter login-shell PATH probe as a fallback so
// GUI-launch PATH stripping still doesn't break CLI discovery (layer-0).
const basePath = [...extraPaths, process.env.PATH || ""].join(path.delimiter);
_cachedShellEnv = {
...process.env,
PATH: mergeLoginShellPath({ basePath }),
};
}
return _cachedShellEnv;
// On macOS/Linux, spawn a login shell to capture the real environment.
try {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const { stdout: envOutput } = await execFileAsync(shell, ['-ilc', 'env'], {
encoding: "utf8",
timeout: 10000,
env: { ...process.env, HOME: home },
});
const envMap = {};
for (const line of envOutput.split("\n")) {
const idx = line.indexOf("=");
if (idx > 0) {
envMap[line.slice(0, idx)] = line.slice(idx + 1);
}
}
const shellPath = envMap.PATH || "";
const mergedPath = [...extraPaths, shellPath, process.env.PATH || ""].join(path.delimiter);
// Layer-0 fix-path: front-load + de-duplicate the login-shell PATH we just
// captured (reuse the `-ilc env` result above — no second shell spawn).
const nextEnv = {
...envMap,
...process.env,
PATH: mergeLoginShellPath({ basePath: mergedPath, runLoginShellPath: () => shellPath }),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
} catch {
// `-ilc env` failed — try a lighter login-shell PATH probe as a fallback so
// GUI-launch PATH stripping still doesn't break CLI discovery (layer-0).
const basePath = [...extraPaths, process.env.PATH || ""].join(path.delimiter);
let loginShellPath = "";
try {
loginShellPath = await defaultRunLoginShellPathAsync();
} catch {
loginShellPath = "";
}
const nextEnv = {
...process.env,
PATH: mergeLoginShellPath({
basePath,
runLoginShellPath: () => loginShellPath,
}),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
}
})().finally(() => {
if (generation === _shellEnvGeneration) {
_shellEnvPromise = null;
}
});
return _shellEnvPromise;
}
/**
@@ -601,7 +697,9 @@ async function getShellEnv() {
* their rc file and clicks "Refresh Status" without restarting the app.
*/
function invalidateShellEnvCache() {
_shellEnvGeneration += 1;
_cachedShellEnv = null;
_shellEnvPromise = null;
}
module.exports = {
@@ -624,7 +722,9 @@ module.exports = {
resolveCodexExecutableForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveSdkBinPathAsync,
resolveCliFromPath,
resolveCliFromPathAsync,
toUnpackedAsarPath,
isPlausibleCliVersionOutput,
mergeLoginShellPath,

View File

@@ -33,7 +33,9 @@ const {
normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveSdkBinPathAsync,
resolveCliFromPath,
resolveCliFromPathAsync,
isPlausibleCliVersionOutput,
getShellEnv,
getFreshIdlePrompt,
@@ -640,7 +642,9 @@ function createHandlerContext(ipcMain) {
normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk,
resolveSdkBinPath,
resolveSdkBinPathAsync,
resolveCliFromPath,
resolveCliFromPathAsync,
probeClaudeAuth,
probeCopilotAuth,
probeCodexAuth,

View File

@@ -67,6 +67,22 @@ function loadBridgeWithMocks(options = {}) {
typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveCliFromPathAsync: async (...args) =>
typeof options.resolveCliFromPathAsync === "function"
? options.resolveCliFromPathAsync(...args)
: typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveSdkBinPath: (...args) =>
typeof options.resolveSdkBinPath === "function"
? options.resolveSdkBinPath(...args)
: null,
resolveSdkBinPathAsync: async (...args) =>
typeof options.resolveSdkBinPathAsync === "function"
? options.resolveSdkBinPathAsync(...args)
: typeof options.resolveSdkBinPath === "function"
? options.resolveSdkBinPath(...args)
: null,
getShellEnv: async () => (
typeof options.shellEnv === "function"
? options.shellEnv()

View File

@@ -3,6 +3,20 @@ function createAgentCliHelpers(ctx) {
with (ctx) {
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
let settled = false;
let closed = false;
let timeoutId = null;
let killId = null;
function clearTimers() {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (killId) {
clearTimeout(killId);
killId = null;
}
}
const spawnSpec = prepareCommandForSpawn(command, args || []);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
@@ -15,6 +29,7 @@ function createAgentCliHelpers(ctx) {
let stdout = "";
let stderr = "";
const MAX_BUFFER = 10 * 1024 * 1024; // 10MB
const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : 0;
child.stdout.on("data", (chunk) => {
if (stdout.length < MAX_BUFFER) {
@@ -29,16 +44,44 @@ function createAgentCliHelpers(ctx) {
});
child.once("error", (error) => {
closed = true;
if (settled) return;
settled = true;
clearTimers();
reject(error);
});
child.once("close", (exitCode) => {
closed = true;
clearTimers();
if (settled) return;
settled = true;
resolve({
stdout: stripAnsi(stdout),
stderr: stripAnsi(stderr),
exitCode,
});
});
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
const error = new Error(`Command timed out after ${timeoutMs}ms`);
error.code = "ETIMEDOUT";
try {
if (!closed) child.kill("SIGTERM");
} catch {}
killId = setTimeout(() => {
try {
if (!closed) child.kill("SIGKILL");
} catch {}
}, 750);
if (typeof killId.unref === "function") killId.unref();
reject(error);
}, timeoutMs);
if (typeof timeoutId.unref === "function") timeoutId.unref();
}
});
}
@@ -55,7 +98,7 @@ function createAgentCliHelpers(ctx) {
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env });
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
return {
launched: true,
exitCode: result.exitCode,
@@ -74,7 +117,7 @@ function createAgentCliHelpers(ctx) {
async function runCodexCli(args, options) {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
const codexCliPath = await resolveCliFromPathAsync("codex", shellEnv) || "codex";
return await runCommand(codexCliPath, args, {
cwd: options?.cwd?.trim() || undefined,
env: shellEnv,
@@ -101,13 +144,12 @@ function createAgentCliHelpers(ctx) {
if (cached && now - cached.checkedAt < maxAgeMs) return cached;
const shellEnv = await getShellEnv();
const rawCodexPath = resolveCliFromPath("codex", shellEnv);
if (!rawCodexPath) {
const codexPath = await resolveSdkBinPathAsync("codex", shellEnv);
if (!codexPath) {
const result = { ok: false, checkedAt: now, error: "codex binary not found", code: "ENOENT" };
setCodexValidationCache(result);
return result;
}
const codexPath = resolveSdkBinPath("codex", shellEnv);
try {
// Minimal read-only probe turn through the SDK to confirm auth works.
const { Codex } = await import("@openai/codex-sdk");

View File

@@ -65,8 +65,8 @@ function registerAgentDiscoveryHandlers(ctx) {
}
const resolvedPath = agent.command === "cursor"
? (resolveCliFromPath(agent.command, shellEnv) || "cursor")
: resolveCliFromPath(agent.command, shellEnv); // Layer-1: locate
? (await resolveCliFromPathAsync(agent.command, shellEnv) || "cursor")
: await resolveCliFromPathAsync(agent.command, shellEnv); // Layer-1: locate
if (!resolvedPath || seenPaths.has(resolvedPath)) continue;
const probe = agent.command === "cursor" && resolvedPath === "cursor"
@@ -85,9 +85,7 @@ function registerAgentDiscoveryHandlers(ctx) {
} else if (agent.command === "copilot") {
auth = probeCopilotAuth({});
} else if (agent.command === "codex") {
// codex login status is async; resolve it then inject synchronously.
const codexStatus = await runCodexCli(["login", "status"]).catch(() => null);
auth = probeCodexAuth({ runLoginStatus: () => codexStatus || { exitCode: 1, stdout: "" } });
auth = { authenticated: false, authSource: null };
} else if (agent.command === "cursor") {
auth = {
authenticated: cursorSdkStatus.authenticated,
@@ -119,6 +117,16 @@ function registerAgentDiscoveryHandlers(ctx) {
return agents;
});
ipcMain.handle("netcatty:ai:shell-env:prewarm", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
await getShellEnv();
return { ok: true };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// Resolve a CLI binary path (auto-detect or validate custom path)
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath, refreshShellEnv, apiKeyPresent }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
@@ -133,16 +141,16 @@ function registerAgentDiscoveryHandlers(ctx) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
resolvedPath = normalizeCliPathForPlatform(customPath) || await resolveCliFromPathAsync(command, shellEnv);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
resolvedPath = await resolveCliFromPathAsync(command, shellEnv);
}
if (command === "cursor") {
const cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
apiKeyPresent: Boolean(apiKeyPresent),
});
const cursorPath = resolveCliFromPath(command, shellEnv) || "cursor";
const cursorPath = await resolveCliFromPathAsync(command, shellEnv) || "cursor";
return {
path: cursorSdkStatus.installed ? cursorPath : null,
binPath: cursorSdkStatus.installed ? cursorPath : null,
@@ -186,7 +194,7 @@ function registerAgentDiscoveryHandlers(ctx) {
let state = normalizeCodexIntegrationState(rawOutput);
let effectiveRawOutput = rawOutput;
if (state === "connected_chatgpt") {
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (!validation.ok) {
if (isCodexAuthError(validation)) {
@@ -258,7 +266,7 @@ function registerAgentDiscoveryHandlers(ctx) {
try {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
const codexCliPath = await resolveCliFromPathAsync("codex", shellEnv) || "codex";
const sessionId = `codex_login_${randomUUID()}`;
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
const child = spawn(spawnSpec.command, spawnSpec.args, {

View File

@@ -0,0 +1,22 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
filterExcludedFigSpecs,
isExcludedFigSpec,
} = require("../main/registerBridges.cjs");
test("filters cloud fig specs removed from packaged builds", () => {
assert.equal(isExcludedFigSpec("aws"), true);
assert.equal(isExcludedFigSpec("aws/s3"), true);
assert.equal(isExcludedFigSpec("gcloud"), true);
assert.equal(isExcludedFigSpec("gcloud/compute"), true);
assert.equal(isExcludedFigSpec("az"), true);
assert.equal(isExcludedFigSpec("az/2.53.0"), true);
assert.equal(isExcludedFigSpec("aws-vault"), false);
assert.deepEqual(
filterExcludedFigSpecs(["git", "aws", "aws/s3", "gcloud", "az/2.53.0", "aws-vault"]),
["git", "aws-vault"],
);
});

View File

@@ -1132,7 +1132,6 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
label: tMenu(language, "view"),
submenu: [
{ label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },

View File

@@ -297,6 +297,39 @@ test("buildAppMenu sends Cmd+W to any registered main window renderer", () => {
}
});
test("buildAppMenu keeps app reload click-only so custom reload-like shortcuts reach the renderer", () => {
let capturedTemplate = null;
const Menu = {
buildFromTemplate(template) {
capturedTemplate = template;
return { template };
},
};
buildAppMenu(Menu, { name: "Netcatty" }, false);
const viewMenu = capturedTemplate.find((item) => item.label === "View");
assert.ok(viewMenu);
assert.equal(viewMenu.submenu.some((item) => item.role === "reload"), false);
assert.equal(viewMenu.submenu.some((item) => item.role === "forceReload"), false);
assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+R"), false);
assert.equal(viewMenu.submenu.some((item) => item.accelerator === "CommandOrControl+Shift+R"), false);
const reloadItem = viewMenu.submenu.find((item) => item.label === "Reload");
assert.ok(reloadItem);
assert.equal(reloadItem.role, undefined);
assert.equal(reloadItem.accelerator, undefined);
const calls = [];
reloadItem.click(null, {
reload() {
calls.push("reload");
},
});
assert.deepEqual(calls, ["reload"]);
});
test("requestWindowCommandClose sends command-close to renderer-capable windows", () => {
const sentChannels = [];
const win = {
@@ -434,6 +467,121 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
assert.equal(commandCloseRequests.length, 1);
});
test("main window leaves primary-modifier reload-like shortcuts available to renderer handlers", async () => {
let beforeInputHandler = null;
const ignoreMenuShortcutValues = [];
class BrowserWindowStub {
constructor() {
this.webContents = {
id: 1,
on(channel, handler) {
if (channel === "before-input-event") beforeInputHandler = handler;
},
once() {},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
setIgnoreMenuShortcuts(value) {
ignoreMenuShortcutValues.push(value);
},
setWindowOpenHandler() {},
openDevTools() {},
};
}
on() {}
once() {}
isDestroyed() { return false; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
setOpacity() {}
async loadURL() {}
close() {}
}
const api = createMainWindowApi({
mainWindow: null,
electronApp: null,
currentTheme: "light",
isQuitting: false,
pendingWindowStateWrite: null,
queuedWindowState: null,
windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400,
DEFAULT_WINDOW_HEIGHT: 900,
MIN_WINDOW_WIDTH: 1100,
MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "bypassHeatCheck",
THEME_COLORS: { light: { background: "#fff" } },
unhealthyWebContentsIds: new Set(),
rendererReadySeenByWebContentsId: new Set(),
__dirname,
URL,
require,
console,
setTimeout,
clearTimeout,
getGlobalShortcutBridge() {
return { handleWindowClose: () => false };
},
debugLog() {},
resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return null; },
getDevRendererBaseUrl(url) { return url; },
getWindowBoundsState() { return null; },
queueWindowStateSave() {},
saveWindowStateSync() {},
setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; },
createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {},
registerWindowHandlers() {},
requestWindowCommandClose() {
return true;
},
shouldCloseWindowFromInput,
applyWindowOpacityToWindow() {},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
await api.createWindow(
{
BrowserWindow: BrowserWindowStub,
nativeTheme: {},
app: {},
screen: {},
shell: {},
ipcMain: {},
},
{
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: false,
electronDir: __dirname,
},
);
let prevented = false;
beforeInputHandler({ preventDefault: () => { prevented = true; } }, {
type: "keyDown",
control: true,
shift: true,
key: "R",
});
assert.equal(prevented, false);
assert.deepEqual(ignoreMenuShortcutValues, [false]);
});
test("createWindow registers each main window as an independent app window", async () => {
const registered = [];
const unregistered = [];

View File

@@ -172,9 +172,20 @@ const devServerUrl = process.env.VITE_DEV_SERVER_URL;
// Never treat a packaged app as "dev" even if the user has VITE_DEV_SERVER_URL set globally.
const isDev = !app.isPackaged && !!devServerUrl;
const effectiveDevServerUrl = isDev ? devServerUrl : undefined;
if (isDev) {
app.setName("Netcatty Dev");
app.setPath("userData", path.join(app.getPath("userData"), "dev"));
}
const preload = path.join(__dirname, "preload.cjs");
const isMac = process.platform === "darwin";
const appIcon = path.join(__dirname, "../public/icon.png");
function resolveAppIconPath() {
const candidates = [
path.join(__dirname, "../dist/icon.png"),
path.join(__dirname, "../public/icon.png"),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
const appIcon = resolveAppIconPath();
const electronDir = __dirname;
const APP_PROTOCOL_HEADERS = {

View File

@@ -4,6 +4,16 @@ let bridgesRegistered = false;
let cloudSyncSessionPassword = null;
const { readClipboardFiles, readClipboardImage } = require("../bridges/clipboardFiles.cjs");
const excludedFigSpecPrefixes = ["aws", "gcloud", "az"];
function isExcludedFigSpec(commandName) {
return excludedFigSpecPrefixes.some((prefix) => commandName === prefix || commandName.startsWith(`${prefix}/`));
}
function filterExcludedFigSpecs(specNames) {
return specNames.filter((name) => !isExcludedFigSpec(name));
}
function createBridgeRegistrar(context) {
const {
electronModule,
@@ -207,7 +217,7 @@ function createBridgeRegistrar(context) {
.filter(f => f.endsWith(".js"))
.map(f => f.slice(0, -3));
} catch { /* no local specs dir */ }
const merged = [...new Set([...figSpecs, ...localNames])];
const merged = filterExcludedFigSpecs([...new Set([...figSpecs, ...localNames])]);
return merged;
} catch (err) {
console.warn("[Main] Failed to load fig spec list:", err?.message || err);
@@ -219,6 +229,7 @@ function createBridgeRegistrar(context) {
// Sanitize: reject absolute paths, path traversal, and non-spec characters
if (!commandName || commandName.startsWith("/") || commandName.startsWith("\\") ||
commandName.includes("..") || !/^[@a-zA-Z0-9._/+-]+$/.test(commandName)) return null;
if (isExcludedFigSpec(commandName)) return null;
const { pathToFileURL } = require("url");
const fs = require("fs");
@@ -803,4 +814,4 @@ function createBridgeRegistrar(context) {
return registerBridges;
}
module.exports = { createBridgeRegistrar };
module.exports = { createBridgeRegistrar, filterExcludedFigSpecs, isExcludedFigSpec };

View File

@@ -895,6 +895,9 @@ function createPreloadApi(ctx) {
aiDiscoverAgents: async (options) => {
return ipcRenderer.invoke("netcatty:ai:agents:discover", options);
},
aiPrewarmShellEnv: async () => {
return ipcRenderer.invoke("netcatty:ai:shell-env:prewarm");
},
aiResolveCli: async (params) => {
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
},

View File

@@ -30,14 +30,14 @@ function isSafeRegex(pattern: string): boolean {
* can bypass regex-based filtering. The primary security boundary is the
* permission / confirmation system and OS-level sandboxing.
*/
const compiledDefaultBlocklist: RegExp[] = DEFAULT_COMMAND_BLOCKLIST.flatMap(
const compiledDefaultBlocklist: Array<{ pattern: string; regex: RegExp }> = DEFAULT_COMMAND_BLOCKLIST.flatMap(
(pattern) => {
try {
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping default blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
return [];
}
return [new RegExp(pattern, 'i')];
return [{ pattern, regex: new RegExp(pattern, 'i') }];
} catch {
return [];
}
@@ -79,9 +79,9 @@ export function checkCommandSafety(
): { blocked: boolean; matchedPattern?: string } {
// Fast path: use pre-compiled regexes for the default blocklist
if (blocklist === DEFAULT_COMMAND_BLOCKLIST) {
for (let i = 0; i < compiledDefaultBlocklist.length; i++) {
if (compiledDefaultBlocklist[i].test(command)) {
return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] };
for (const { pattern, regex } of compiledDefaultBlocklist) {
if (regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
return { blocked: false };

View File

@@ -15,7 +15,16 @@ test("AI command blocklist uses the shared JSON source", () => {
});
test("shared default command blocklist covers bypass-style shell execution", () => {
assert.equal(checkCommandSafety("rm -rf /").blocked, true);
assert.equal(checkCommandSafety("rm -r -f /tmp/cache").blocked, true);
assert.equal(checkCommandSafety("rm --recursive --force /tmp/cache").blocked, true);
assert.equal(checkCommandSafety("echo ZWNobyBoaQ== | base64 -d | bash").blocked, true);
assert.equal(checkCommandSafety("eval $payload").blocked, true);
assert.equal(checkCommandSafety("echo $(whoami)").blocked, true);
});
test("default command blocklist reports the pattern that matched", () => {
const result = checkCommandSafety("mkfs.ext4 /dev/sda");
assert.equal(result.blocked, true);
assert.equal(result.matchedPattern, "\\bmkfs\\.");
});

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
CLAUDE_MODEL_PRESETS,
CODEBUDDY_MODEL_PRESETS,
CODEX_MODEL_PRESETS,
getAgentModelPresets,
@@ -19,3 +20,14 @@ test('getAgentModelPresets keeps Codex presets separate from CodeBuddy presets',
assert.deepEqual(getAgentModelPresets('codex'), CODEX_MODEL_PRESETS);
assert.notDeepEqual(CODEBUDDY_MODEL_PRESETS, CODEX_MODEL_PRESETS);
});
test('getAgentModelPresets resolves Windows command paths with backslashes', () => {
assert.deepEqual(
getAgentModelPresets('C\\Users\\foo\\AppData\\Roaming\\npm\\codex.cmd'),
CODEX_MODEL_PRESETS,
);
assert.deepEqual(
getAgentModelPresets('C\\Program Files\\nodejs\\claude.exe'),
CLAUDE_MODEL_PRESETS,
);
});

View File

@@ -477,7 +477,11 @@ export const CODEBUDDY_MODEL_PRESETS: AgentModelPreset[] = [
export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[] {
if (!agentCommand) return [];
const basename = agentCommand.split('/').pop()?.toLowerCase() ?? '';
// Split on both POSIX (/) and Windows (\) separators so command paths like
// "C:\\Users\\foo\\codex.cmd" resolve to the right basename. Splitting only
// on "/" leaves the full path intact on Windows, which never matches the
// preset prefixes below and yields an empty list (presets silently lost).
const basename = agentCommand.split(/[\\/]/).pop()?.toLowerCase() ?? '';
if (basename.startsWith('claude')) return CLAUDE_MODEL_PRESETS;
if (basename.startsWith('codex')) return CODEX_MODEL_PRESETS;
if (basename.startsWith('cursor')) return CURSOR_MODEL_PRESETS;

View File

@@ -1,5 +1,5 @@
[
"\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}",
"\\brm\\s+(?=[^\\n;&|]*(?:-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\\b|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\\b|-[a-zA-Z]*r[a-zA-Z]*\\b[^\\n;&|]*-[a-zA-Z]*f[a-zA-Z]*\\b|-[a-zA-Z]*f[a-zA-Z]*\\b[^\\n;&|]*-[a-zA-Z]*r[a-zA-Z]*\\b|--recursive\\b[^\\n;&|]*(?:--force\\b|-[a-zA-Z]*f[a-zA-Z]*\\b)|(?:--force\\b|-[a-zA-Z]*f[a-zA-Z]*\\b)[^\\n;&|]*--recursive\\b))",
"\\bmkfs\\.",
"\\bdd\\s+if=.*\\s+of=/dev/",
"\\b(shutdown|reboot|poweroff|halt)\\b",

497
package-lock.json generated
View File

@@ -16,7 +16,6 @@
"@aws-sdk/client-s3": "^3.956.0",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
"@openai/codex-sdk": "^0.136.0",
@@ -27,7 +26,6 @@
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@streamdown/cjk": "^1.0.2",
@@ -70,7 +68,6 @@
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@withfig/autocomplete-types": "^1.31.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.3.3",
@@ -90,7 +87,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.3.161",
"@cursor/sdk": "^1.0.18",
"@github/copilot-sdk": "1.0.0",
"@tencent-ai/agent-sdk": "^0.3.169",
"@tencent-ai/agent-sdk": "^0.3.173",
"@vscode/windows-process-tree": "^0.7.0"
}
},
@@ -2977,27 +2974,6 @@
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@google/genai": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.33.0.tgz",
"integrity": "sha512-ThUjFZ1N0DU88peFjnQkb8K198EWaW2RmmnDShFQ+O+xkIH9itjpRe358x3L/b4X/A7dimkvq63oz49Vbh7Cog==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.24.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@@ -3116,102 +3092,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -3679,16 +3559,6 @@
"node": ">=14.18.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4398,24 +4268,6 @@
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -6496,20 +6348,10 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/@tencent-ai/agent-sdk": {
"version": "0.3.169",
"resolved": "https://registry.npmjs.org/@tencent-ai/agent-sdk/-/agent-sdk-0.3.169.tgz",
"integrity": "sha512-iwMbrV+7KzMRjDTZPwz9VAIxokDJ1ybOY58YODSCgrsh/2cILFWl8jhMElpZScS3zsUb5IvB2xbaXhxtIEqKBw==",
"version": "0.3.173",
"resolved": "https://registry.npmjs.org/@tencent-ai/agent-sdk/-/agent-sdk-0.3.173.tgz",
"integrity": "sha512-8aAZhQ1pMhcAdsPrLpk93abGMQGVdm3kf6WGMLgf1TJLW/YrHskJeOxFCCqkxyx3SLu68hi2h9EcyvQEPcta6Q==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6520,6 +6362,16 @@
"zod": "^4.0.0"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -7011,13 +6863,6 @@
"pnpm": ">=9"
}
},
"node_modules/@withfig/autocomplete-types": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@withfig/autocomplete-types/-/autocomplete-types-1.31.0.tgz",
"integrity": "sha512-TSZDo5jvEaeIHqmHY6Wkd3gBqVbxcHQVdkF6N1J8CXRBuQZpjUVci15/HPNYe0nKLvsomBWIRsTP3m1zr9pv3A==",
"dev": true,
"license": "MIT"
},
"node_modules/@withfig/autocomplete/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
@@ -7167,6 +7012,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -7277,6 +7123,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7286,6 +7133,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -7683,6 +7531,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -7718,15 +7567,6 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -7902,12 +7742,6 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -8419,6 +8253,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -8431,6 +8266,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-support": {
@@ -9035,21 +8871,6 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -9273,6 +9094,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"devOptional": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -10150,34 +9972,6 @@
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -10331,35 +10125,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -10583,33 +10348,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -10654,19 +10392,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -11053,6 +10778,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -11278,6 +11004,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11380,21 +11107,6 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
@@ -11483,15 +11195,6 @@
"node": ">=6"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -11603,27 +11306,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -13218,6 +12900,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -13864,12 +13547,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -14025,28 +13702,6 @@
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==",
"license": "ISC"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -14897,41 +14552,6 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@@ -15920,21 +15540,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -15963,19 +15569,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -17108,51 +16702,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -44,7 +44,6 @@
"@aws-sdk/client-s3": "^3.956.0",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
"@openai/codex-sdk": "^0.136.0",
@@ -55,7 +54,6 @@
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@streamdown/cjk": "^1.0.2",
@@ -95,7 +93,6 @@
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@withfig/autocomplete-types": "^1.31.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.3.3",
@@ -115,7 +112,7 @@
"@anthropic-ai/claude-agent-sdk": "^0.3.161",
"@cursor/sdk": "^1.0.18",
"@github/copilot-sdk": "1.0.0",
"@tencent-ai/agent-sdk": "^0.3.169",
"@tencent-ai/agent-sdk": "^0.3.173",
"@vscode/windows-process-tree": "^0.7.0"
},
"overrides": {

View File

@@ -19,6 +19,7 @@
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { execFileSync } = require("node:child_process");
const LC_UUID = 0x1b;
const MH_MAGIC_64 = 0xfeedfacf; // thin 64-bit, little-endian on disk
@@ -105,15 +106,32 @@ function patchMachOFile(file, uuid) {
return result;
}
function adHocSignAppBundle(appPath, options = {}) {
const hostPlatform = options.hostPlatform || process.platform;
const execFile = options.execFileSync || execFileSync;
if (hostPlatform !== "darwin") {
console.warn(
`[afterPack] Skipping ad-hoc codesign for ${appPath}; host platform is ${hostPlatform}`,
);
return false;
}
execFile("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], {
stdio: ["ignore", "pipe", "pipe"],
});
return true;
}
/** @param {import('electron-builder').AfterPackContext} context */
async function afterPack(context) {
if (context.electronPlatformName !== "darwin") return;
const appId = context.packager.appInfo.id || "com.netcatty.app";
const productFilename = context.packager.appInfo.productFilename;
const appPath = path.join(context.appOutDir, `${productFilename}.app`);
const exePath = path.join(
context.appOutDir,
`${productFilename}.app`,
appPath,
"Contents",
"MacOS",
productFilename,
@@ -137,6 +155,15 @@ async function afterPack(context) {
`${oldUuids.map((h) => formatUuid(Buffer.from(h, "hex"))).join(", ")} -> ${formatUuid(uuid)} ` +
`(${patched} slice(s), appId=${appId})`,
);
// The official Developer ID signing step runs after afterPack and replaces
// this temporary signature. Local unsigned builds skip that step, so the
// patched app bundle still needs a valid ad-hoc signature or macOS kills it
// before Electron can start. Signing the whole bundle also covers Electron's
// nested frameworks, which codesign validates as subcomponents.
if (adHocSignAppBundle(appPath)) {
console.log("[afterPack] Ad-hoc signed patched macOS app for local unsigned builds");
}
}
module.exports = afterPack;
@@ -145,3 +172,4 @@ module.exports.deriveUuid = deriveUuid;
module.exports.formatUuid = formatUuid;
module.exports.patchMachOBuffer = patchMachOBuffer;
module.exports.patchMachOFile = patchMachOFile;
module.exports.adHocSignAppBundle = adHocSignAppBundle;

View File

@@ -2,6 +2,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
adHocSignAppBundle,
deriveUuid,
patchMachOBuffer,
} = require("./afterPackMacUuid.cjs");
@@ -115,3 +116,44 @@ test("patchMachOBuffer reports zero when there is no LC_UUID", () => {
const { patched } = patchMachOBuffer(buf, deriveUuid("com.netcatty.app"));
assert.equal(patched, 0);
});
test("adHocSignAppBundle signs the full app bundle on macOS hosts", () => {
const calls = [];
const didSign = adHocSignAppBundle("/tmp/Netcatty.app", {
hostPlatform: "darwin",
execFileSync: (bin, args, options) => {
calls.push({ bin, args, options });
},
});
assert.equal(didSign, true);
assert.deepEqual(calls, [
{
bin: "codesign",
args: [
"--force",
"--deep",
"--sign",
"-",
"--timestamp=none",
"/tmp/Netcatty.app",
],
options: { stdio: ["ignore", "pipe", "pipe"] },
},
]);
});
test("adHocSignAppBundle skips non-macOS hosts", () => {
let called = false;
const didSign = adHocSignAppBundle("/tmp/Netcatty.app", {
hostPlatform: "linux",
execFileSync: () => {
called = true;
},
});
assert.equal(didSign, false);
assert.equal(called, false);
});

View File

@@ -9,7 +9,7 @@ declare global {
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
aiExec?(sessionId: string, command: string, chatSessionId?: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
aiCattyCancelExec?(chatSessionId: string): Promise<{ ok: boolean; error?: string }>;
aiDiscoverAgents?(): Promise<Array<{
aiDiscoverAgents?(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<Array<{
command: string;
name: string;
icon: string;
@@ -27,7 +27,8 @@ declare global {
acpCommand?: string;
acpArgs?: string[];
}>>;
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
aiPrewarmShellEnv?(): Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean }): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;

View File

@@ -51,7 +51,6 @@ export default defineConfig(() => {
'@radix-ui/react-popover',
'@radix-ui/react-scroll-area',
'@radix-ui/react-select',
'@radix-ui/react-slot',
'@radix-ui/react-tabs',
],
'vendor-xterm': [