Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c8f11194 | ||
|
|
4d1a7ea55a | ||
|
|
babe06a944 | ||
|
|
9e31d53bdd | ||
|
|
ea24841939 | ||
|
|
bf9f557e42 | ||
|
|
106e748a9b | ||
|
|
94fff62f9b | ||
|
|
324253f23a | ||
|
|
e9a2e44a91 | ||
|
|
7b4f046001 |
3
App.tsx
3
App.tsx
@@ -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
|
||||
)));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Активен',
|
||||
|
||||
@@ -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': 'Поиск сессий…',
|
||||
|
||||
@@ -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': 'Отключить режим трансляции',
|
||||
|
||||
@@ -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': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
@@ -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': '活跃',
|
||||
|
||||
@@ -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': '搜索会话…',
|
||||
|
||||
@@ -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 中最多支持一个跳板机。',
|
||||
|
||||
@@ -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': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
340
application/state/useAISettingsState.ts
Normal file
340
application/state/useAISettingsState.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ function TerminalPopupPageInner() {
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
compactToolbar
|
||||
lineTimestampsAvailable={false}
|
||||
knownHosts={knownHosts}
|
||||
isVisible
|
||||
isFocused
|
||||
|
||||
38
components/ai/userSkillsStatusEvents.ts
Normal file
38
components/ai/userSkillsStatusEvents.ts
Normal 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?.();
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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={(
|
||||
|
||||
269
components/systemManager/hooks/useAsyncRecordCache.ts
Normal file
269
components/systemManager/hooks/useAsyncRecordCache.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
63
components/terminal/TerminalTimestampGutter.test.ts
Normal file
63
components/terminal/TerminalTimestampGutter.test.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
281
components/terminal/TerminalTimestampGutter.tsx
Normal file
281
components/terminal/TerminalTimestampGutter.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
components/terminal/TerminalView.test.tsx
Normal file
39
components/terminal/TerminalView.test.tsx
Normal 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\}/);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
135
components/terminal/runtime/terminalLineTimestamps.test.ts
Normal file
135
components/terminal/runtime/terminalLineTimestamps.test.ts
Normal 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, []);
|
||||
});
|
||||
436
components/terminal/runtime/terminalLineTimestamps.ts
Normal file
436
components/terminal/runtime/terminalLineTimestamps.ts
Normal 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();
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/**/*',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, {
|
||||
|
||||
22
electron/bridges/registerBridgesFigSpec.test.cjs
Normal file
22
electron/bridges/registerBridgesFigSpec.test.cjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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\\.");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
497
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
5
types/global/netcatty-bridge-ai.d.ts
vendored
5
types/global/netcatty-bridge-ai.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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': [
|
||||
|
||||
Reference in New Issue
Block a user