Compare commits

...
Author SHA1 Message Date
GOUKI9999and陈大猫 0a4ff8875f feat(ai): make unrecognized shell prompts an actionable unknown state instead of a terminal block (#4198)
* feat(ai): make unrecognized shell prompts an actionable unknown state instead of a terminal block

Composes #3935 (advisory semantics), #4110 (OSC 133 marker authority + pending-input guards) and #4195 (asymmetric harm / state-model proposal). An unrecognized prompt on a live shell now yields a non-terminal SHELL_READINESS_UNKNOWN result with a read-only/confirm viable path; PROVEN-busy states keep the exact hard SHELL_NOT_READY block. Never blind-types into unknown; never claims ready without evidence.

* fix(ai): propagate unknown readiness code through the active Catty SDK path

---------

Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-11-15 01:27:43 +08:00
rnaranyand陈大猫 8433302959 fix(i18n): complete zh-TW strings and unify Taiwan terminology (#4185)
* fix(i18n): complete zh-TW strings and unify Taiwan terminology

- Translate the 19 settings.plugins.sources.* keys that were still in
  Simplified Chinese, and add the 9 missing ai.providers.headers.* keys.
- Add the two zh-CN keys the dialog already looked up (createWorkspace.title,
  field.selectHosts) so those labels no longer fall back to English.
- Keep {{name}} / {{name:default}} literal in snippets.field.variablesHelp;
  the previous text translated the metavariable names.
- Unify zh-TW terms in the shipped UI: 外掛, 市集, 行程, 指令碼, 金鑰,
  佔位符, 檔案系統, 網路, 資料, 使用者, 預設.
- Tighten localeParityLocales: zh-CN and zh-TW are at full parity, so move
  them out of LOCALES_PENDING_PARITY and check interpolation placeholders for
  every translated locale, not just es.

* fix(i18n): finish zh-TW plugin wording and :default placeholders

Review follow-up on the terminology pass:

- Replace the remaining mainland terms on the plugin settings screen:
  導入→匯入, 卸載→解除安裝, 存儲→儲存, 遠程→遠端, 加載→載入. 網路設備 is
  correct Taiwan usage and stays.
- Widen the interpolation token regex so {{name:default}} is compared too;
  the previous \w+ form silently ignored the colon and could not catch a
  translated metavariable. Add a direct assertion for that form.

* test(ci): pin Vite contract probe to numeric loopback

---------

Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-11-14 23:37:53 +08:00
陈大猫 51cb14955c fix(logs): finalize session logs on quit and pre-connect close (#4236)
* fix(logs): finalize session logs when the app quits without closing tabs

Fixes #4235

* fix(logs): finalize logs when unconnected sessions close

Capture never-connected terminal logs at explicit session removal, including empty buffers, without finalizing logs during runtime remounts. Cover tab close, failed-dialog close, batch close, and workspace close with persistence regressions.

Fixes #4235

* fix(logs): tolerate unavailable finalization IPC

Preserve window close and layout saves when session-log finalization IPC cannot be set up, and clear its timeout on setup failures.

Follow-up for #4235 / PR #4236.
2026-11-14 21:36:08 +08:00
陈大猫 1d5cef09ec fix(settings): index later-added settings in search catalog (Fixes #4230) (#4232)
* fix(settings): index later-added settings in search catalog

Fixes #4230

Follow-up after #4231. Settings search still missed later-added
rows such as Appearance "Code font" (代码字体) and System session
log "Add timestamps" (时间戳), which already rendered in their
tabs. Index those destinations and other visible setting rows
that had the same catalog/anchor omission.

* fix(settings): fall back to visible parent when search target is gated
2026-11-14 21:30:27 +08:00
陈大猫 cf7f998689 fix(ci): prevent plugin-contract lint-and-test hang (#4240) 2026-11-14 20:17:13 +08:00
60 changed files with 2375 additions and 145 deletions

No files matched your search

+17
View File
@@ -1220,6 +1220,23 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export function startRestoredSessionConnectionLogImpl(getCtx: AppContextGetter, session: TerminalSession) {
const { addConnectionLog, systemInfoRef } = getCtx();
const { username, hostname } = systemInfoRef.current;
addConnectionLog({
sessionId: session.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
protocol: session.protocol || 'ssh',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
}
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
+12 -2
View File
@@ -104,7 +104,7 @@ import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -1895,8 +1895,18 @@ export function AppSideEffects() {
setActiveTabId('vault');
}, [setActiveTabId]);
const restoredSessionLogsStartedRef = useRef(new Set<string>());
// A restored tab retains its session ID, but its previous ConnectionLog is closed.
// Start the new incarnation's log before updateSessionStatus clears restoreState.
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
const session = sessionByIdRef.current.get(sessionId);
if (session?.restoreState === 'restored-disconnected'
&& (status === 'connecting' || status === 'connected')
&& !restoredSessionLogsStartedRef.current.has(sessionId)) {
restoredSessionLogsStartedRef.current.add(sessionId);
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog, systemInfoRef }), session);
}
updateSessionStatus(sessionId, status);
if (status === 'connected') {
const session = sessionByIdRef.current.get(sessionId);
@@ -1904,7 +1914,7 @@ export function AppSideEffects() {
updateHostLastConnected(session.hostId);
}
}
}, [updateSessionStatus, updateHostLastConnected]);
}, [addConnectionLog, updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
// Functional update + identity-preserving updateHosts: only the patched
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import React, { useEffect, useRef } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { useSessionState } from '../state/useSessionState';
import { captureClosingTerminalSessions, useTerminalSessionCloseCapture } from '../state/terminalSessionCloseCapture';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { TerminalConnectionProgress } from '../../components/terminal/TerminalConnectionProgress';
import { I18nProvider } from '../i18n/I18nProvider';
import { handleTerminalDataCaptureImpl } from './AppHandlers';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('explicit close persists never-connected logs; runtime remounts leave them open', async (t) => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
try {
for (const scenario of [
{ name: 'tab X before connected, empty buffer', status: 'connecting', data: '' },
{ name: 'failed-dialog close, empty buffer', status: 'disconnected', data: '' },
{ name: 'failed-dialog close preserves buffered diagnostics', status: 'disconnected', data: 'SSH diagnostic\r\n' },
{ name: 'batch close before connected', status: 'connecting', data: '', closeMode: 'batch' },
{ name: 'workspace close before connected', status: 'connecting', data: '', closeMode: 'workspace' },
] as const) {
await t.test(scenario.name, async () => {
let vault!: ReturnType<typeof useVaultState>;
let state!: ReturnType<typeof useSessionState>;
let root: ReactTestRenderer | undefined;
let sessionId!: string;
let logId!: string;
const captures: Promise<void>[] = [];
const capturedData: string[] = [];
function TerminalProbe() {
const hasConnectedRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const buffer = useRef(createConnectionLogBuffer(10000));
useEffect(() => {
buffer.current.append(scenario.data);
return () => { buffer.current.reset(); };
}, []);
useTerminalSessionCloseCapture({
sessionId, enabled: true, hasConnectedRef, terminalDataCapturedRef,
finalizeTerminalLogData: () => buffer.current.toString(),
onTerminalDataCapture: (id, data, options) => {
assert.equal(options.finalized, true);
assert.ok(state.sessions.some((session) => session.id === id), 'capture starts before removal');
terminalDataCapturedRef.current = true;
capturedData.push(data);
const pending = handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: state.sessions,
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), id, data);
captures.push(pending);
return pending;
},
});
return scenario.status === 'disconnected' ? React.createElement(
I18nProvider, { locale: 'en' }, React.createElement(TerminalConnectionProgress, {
status: 'disconnected', error: 'Connection refused', timeLeft: 0,
isCancelling: false, showLogs: false, progressLogs: [],
onCancelConnect: () => {}, onCloseSession: () => state.closeSession(sessionId),
}),
) : null;
}
function Probe({ showTerminal = true }) {
vault = useVaultState();
state = useSessionState({ persistSessionRestore: false });
return showTerminal && state.sessions.some((session) => session.id === sessionId)
? React.createElement(TerminalProbe) : null;
}
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
try {
await mount();
await act(async () => { sessionId = state.createLocalTerminal(); });
await act(async () => {
state.updateSessionStatus(sessionId, scenario.status);
const session = state.sessions.find((candidate) => candidate.id === sessionId)!;
logId = vault.addConnectionLog({
sessionId, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username,
protocol: session.protocol, startTime: Date.now(),
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
await vault.updateConnectionLog(logId, {});
});
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
let otherId!: string;
await act(async () => { otherId = state.createLocalTerminal(); });
await act(async () => { state.createWorkspaceFromSessions(sessionId, otherId, 'right'); });
}
// Recycle the renderer while retaining the session, as with a pane
// remount. Neither disposal nor the replacement runtime may end its log.
await act(async () => { root!.update(React.createElement(Probe, { showTerminal: false })); });
await act(async () => { root!.update(React.createElement(Probe)); });
assert.equal(captures.length, 0);
assert.equal(vault.connectionLogs.find((log) => log.id === logId)?.endTime, undefined);
// Both UI actions converge here. This exercises the actual state
// removal, owning-terminal hook, capture handler and vault persistence.
await act(async () => {
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
state.closeWorkspace(state.workspaces[0].id);
} else if ('closeMode' in scenario && scenario.closeMode === 'batch') {
state.closeSessions([sessionId]);
} else if (scenario.status === 'disconnected') {
// Click the actual Close button rendered inside the failed dialog.
root!.root.findByType('button').props.onClick();
} else {
state.closeSession(sessionId);
}
await Promise.all(captures);
});
assert.equal(state.sessions.some((session) => session.id === sessionId), false);
assert.deepEqual(capturedData, [scenario.data]);
await act(async () => { state.closeSession(sessionId); });
assert.equal(captures.length, 1, 'removed runtimes must unregister their capture');
await act(async () => { root!.unmount(); });
root = undefined;
await mount();
const persisted = vault.connectionLogs.find((log) => log.id === logId)!;
assert.ok(persisted.endTime, 'a durable endTime must exist even without terminal output');
assert.equal(persisted.terminalData || '', scenario.data);
} finally {
await act(async () => { root?.unmount(); });
}
});
}
} finally {
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('close capture skips non-owning, already connected and already captured runtimes', async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
try {
for (const scenario of [
{ name: 'attached popup / inactive restored placeholder', enabled: false, connected: false, captured: false },
{ name: 'connected terminal uses existing drain path', enabled: true, connected: true, captured: false },
{ name: 'quit already captured the log', enabled: true, connected: false, captured: true },
]) {
await t.test(scenario.name, async () => {
let calls = 0;
let root!: ReactTestRenderer;
function Probe() {
useTerminalSessionCloseCapture({
sessionId: 'guarded', enabled: scenario.enabled,
hasConnectedRef: useRef(scenario.connected),
terminalDataCapturedRef: useRef(scenario.captured),
finalizeTerminalLogData: () => { calls++; return ''; },
onTerminalDataCapture: () => { calls++; },
});
return null;
}
await act(async () => { root = create(React.createElement(Probe)); });
try {
await act(async () => { captureClosingTerminalSessions(['guarded']); });
assert.equal(calls, 0);
} finally {
await act(async () => { root.unmount(); });
}
});
}
} finally {
if (previous) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previous);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
}
});
test('both UI close paths reach session removal and Terminal registers the owning capture', () => {
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
const app = read('./AppView.tsx');
assert.match(app, /onCloseSession=\{closeSession\}/);
assert.match(read('../../components/top-tabs/TopTabItems.tsx'), /onClick=\{\(e\) => onCloseSession\(session\.id, e\)\}/);
const terminal = read('../../components/Terminal.tsx');
assert.match(terminal, /useTerminalSessionCloseCapture\(\{\s*sessionId,\s*enabled: !attachExistingSession && restoreState !== "restored-disconnected",\s*hasConnectedRef,\s*terminalDataCapturedRef,\s*finalizeTerminalLogData,\s*onTerminalDataCapture: handleTerminalDataCaptureOnce,/);
const handler = terminal.slice(terminal.indexOf('const handleCloseDisconnectedSession ='), terminal.indexOf('const handleHostKeyClose ='));
assert.match(handler, /onCloseSession\?\.\(sessionId\)/);
assert.match(read('../../components/TerminalLayer.tsx'), /onCloseSession\(sessionId\)/);
assert.match(read('../../components/terminal/TerminalView.tsx'), /onCloseSession: handleCloseDisconnectedSession/);
assert.match(read('../../components/terminal/TerminalConnectionProgress.tsx'), /onClick=\{onCloseSession\}/);
});
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { withVaultImportLock } from '../state/vaultManagedImportLock';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import type { TerminalSession } from '../../domain/models';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl } from './AppHandlers';
import { createPreloadApi } from '../../electron/preload/api.cjs';
import { finalizeSessionLogs } from '../../electron/bridges/sessionLogShutdown.cjs';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('quit persists replay before acknowledgment; restored reconnect keeps the old log and starts a new one', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
let vault!: ReturnType<typeof useVaultState>;
let root: ReactTestRenderer | undefined;
const Probe = () => { vault = useVaultState(); return null; };
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
const session: TerminalSession = {
id: 'restorable-tab', hostId: 'host', hostLabel: 'Host', hostname: 'host.test',
username: 'user', protocol: 'ssh', status: 'connected',
};
const systemInfoRef = { current: { username: 'local-user', hostname: 'local-host' } };
const capture = (data: string) => handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: [session],
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), session.id, data);
try {
await mount();
let oldId!: string;
await act(async () => {
oldId = vault.addConnectionLog({
sessionId: session.id, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username, protocol: 'ssh', startTime: 1,
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
// Wait for the initial catalog write before blocking the capture write.
await vault.updateConnectionLog(oldId, {});
});
const buffer = createConnectionLogBuffer(10000);
buffer.append('output before quitting\r\n');
const ipcMain = new EventEmitter();
const ipcRenderer = new EventEmitter() as EventEmitter & { send: (channel: string, payload: unknown) => void };
const webContents = { send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
const off = api.onFinalizeSessionLogs(() => capture(buffer.toString()));
let release!: () => void;
const blockedWrite = withVaultImportLock('connection-logs', () => new Promise<void>((resolve) => { release = resolve; }));
await tick();
let acknowledged = false;
await act(async () => {
const quit = finalizeSessionLogs(webContents, { ipcMain }).then((success: boolean) => {
acknowledged = true;
assert.equal(success, true);
});
await tick();
assert.equal(acknowledged, false, 'main must wait for the persistence queue');
release();
await blockedWrite;
await quit;
});
off();
// Remounting reads durable storage. Node has no IndexedDB, so this also
// exercises the existing localStorage replay fallback.
await act(async () => { root?.unmount(); });
await mount();
const oldLog = vault.connectionLogs.find((log) => log.id === oldId)!;
assert.ok(oldLog.endTime);
assert.equal(oldLog.terminalData, 'output before quitting\r\n');
await act(async () => {
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog: vault.addConnectionLog, systemInfoRef }), {
...session, status: 'disconnected', restoreState: 'restored-disconnected',
});
});
const newLog = vault.connectionLogs.find((log) => log.id !== oldId)!;
assert.equal(newLog.sessionId, session.id);
assert.equal(newLog.endTime, undefined);
assert.equal(newLog.terminalData, undefined);
await act(async () => { await capture('output after reconnect\r\n'); });
await act(async () => { root?.unmount(); });
await mount();
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.terminalData, oldLog.terminalData);
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.endTime, oldLog.endTime);
assert.equal(vault.connectionLogs.find((log) => log.id === newLog.id)?.terminalData, 'output after reconnect\r\n');
assert.ok(vault.connectionLogs.find((log) => log.id === newLog.id)?.endTime);
} finally {
await act(async () => { root?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('terminal quit listener drains output and awaits capture without tearing down tabs', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const start = source.indexOf('onFinalizeSessionLogs?.(async () => {');
assert.ok(start > 0);
const listener = source.slice(start, source.indexOf('const attachHomeWebContentsIdRef', start));
assert.match(listener, /await flushPendingTerminalWritesBeforeHibernate\(term\)/);
assert.match(listener, /await handleTerminalDataCaptureOnce\(sessionId, finalizeTerminalLogData\(\), \{ finalized: true \}\)/);
assert.doesNotMatch(listener, /closeSession|teardown/);
const effects = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const handler = effects.slice(effects.indexOf('const handleSessionStatusChange ='), effects.indexOf('const handleUpdateHostFromTerminal ='));
assert.ok(handler.indexOf('startRestoredSessionConnectionLogImpl(') < handler.indexOf('updateSessionStatus(sessionId, status)'));
assert.match(handler, /restoredSessionLogsStartedRef\.current\.has\(sessionId\)/);
});
@@ -12,16 +12,23 @@ import zhTW from "./zh-TW.ts";
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const LOCALES_PENDING_PARITY = ["ru"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
[...value.matchAll(/\{\{\s*[^}]+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("interpolationTokens reads the :default placeholder form", () => {
assert.deepEqual(interpolationTokens("{{name}} or {{name:default}}"), [
"name",
"name:default",
]);
});
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
@@ -42,22 +49,31 @@ test("es defines no message keys that en does not define", () => {
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
test("every translated locale keeps the same interpolation placeholders as en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
for (const locale of locales) {
const mismatched: string[] = [];
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
for (const key of Object.keys(locale.messages)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(locale.messages[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`${locale.name} changes interpolation placeholders for ${mismatched.length} keys`,
);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
+2
View File
@@ -121,9 +121,11 @@ export const zhCNCoreMessages: Messages = {
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.createWorkspace.title': '创建工作区',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
'field.selectHosts': '选择主机',
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'placeholder.searchHosts': '搜索主机或标签...',
+1 -1
View File
@@ -969,7 +969,7 @@ export const zhCNTerminalMessages: Messages = {
'snippets.variables.placeholderDefault': '默认:{value}',
'snippets.variables.required': '请填写此变量',
'snippets.variables.run': '运行',
'snippets.field.variablesHelp': '在脚本中使用 {{名称}} 或 {{名称:默认值}} 定义变量。',
'snippets.field.variablesHelp': '在脚本中使用 {{name}} 或 {{name:default}} 作为占位符。',
'snippets.field.variablesDetected': '变量',
'snippets.field.variableDefault': '默认 {value}',
+12 -3
View File
@@ -39,6 +39,15 @@ export const zhTWAiMessages: Messages = {
'ai.providers.noApiKey': '未設定 API Key',
'ai.providers.configure': '設定',
'ai.providers.remove': '移除',
'ai.providers.headers': '自訂 HTTP 請求標頭',
'ai.providers.headers.help': '為此服務新增選用請求標頭。內容會隱藏顯示,並在儲存時加密。',
'ai.providers.headers.name': '請求標頭名稱',
'ai.providers.headers.value': '請求標頭內容',
'ai.providers.headers.add': '新增請求標頭',
'ai.providers.headers.remove': '刪除請求標頭',
'ai.providers.headers.invalid': '請填寫有效且不重複的請求標頭。Content-Length 和 Transfer-Encoding 由應用程式自動設定。',
'ai.providers.headers.loadError': '無法解鎖已儲存的請求標頭。請取消編輯,解鎖系統鑰匙圈後重試。',
'ai.providers.headers.saveError': '無法安全儲存。請確認系統鑰匙圈可用後重試。',
'ai.providers.name': '顯示名稱',
'ai.providers.name.placeholder': '例如 我的供應商',
'ai.providers.style': '協定風格',
@@ -385,8 +394,8 @@ export const zhTWAiMessages: Messages = {
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
"ai.privacy.patterns": "自訂正規表示式",
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
"ai.privacy.connectionScope": "佔位符在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用佔位符。",
"ai.privacy.apply": "套用",
"ai.privacy.applying": "正在套用...",
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
@@ -657,7 +666,7 @@ export const zhTWAiMessages: Messages = {
'ai.chatHistory.messageCap.unit': '則訊息',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
'ai.codex.bridgeError': 'Codex 主行程處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發行程,然後重試。',
// AI Web Search
'ai.webSearch.title': '網路搜尋',
+48 -48
View File
@@ -6,7 +6,7 @@ export const zhTWCoreMessages: Messages = {
"settings.plugins.github.repository": "GitHub 儲存庫",
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置指令碼,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
"settings.plugins.github.confirm": "安裝並保持停用",
"settings.plugins.github.download": "下載並審查",
@@ -117,8 +117,8 @@ export const zhTWCoreMessages: Messages = {
'confirm.deleteIdentity': '刪除身分 "{name}"?',
'confirm.removeProvider': '移除供應商 "{name}"?',
'confirm.closeBusyTerminal.title': '確認關閉',
'confirm.closeBusyTerminal.message': '程序 "{command}" 仍在執行,關閉後會被終止。',
'confirm.closeBusyTerminal.messageWithMore': '程序 "{command}" 及其他 {count} 個正在執行的程序將被終止。',
'confirm.closeBusyTerminal.message': '行程 "{command}" 仍在執行,關閉後會被終止。',
'confirm.closeBusyTerminal.messageWithMore': '行程 "{command}" 及其他 {count} 個正在執行的行程將被終止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '關閉',
'dialog.createWorkspace.title': '建立工作區',
@@ -296,7 +296,7 @@ export const zhTWCoreMessages: Messages = {
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩潰日誌',
'settings.system.crashLogs.description': '檢視主程序錯誤日誌,協助診斷異常行為。',
'settings.system.crashLogs.description': '檢視主行程錯誤日誌,協助診斷異常行為。',
'settings.system.crashLogs.noLogs': '未找到崩潰日誌。',
'settings.system.crashLogs.entries': '{count} 條記錄',
'settings.system.crashLogs.clear': '清除所有日誌',
@@ -1245,16 +1245,16 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.tab.plugins': '外掛程式',
"settings.plugins.author.official": "官方維護",
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.installed": "已安裝外掛",
"settings.plugins.manager.import": "匯入 .ncpkg",
"settings.plugins.manager.preview": "匯入的外掛保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "匯入本地外掛包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.manager.remove": "解除安裝",
"settings.plugins.manager.removeNote": "解除安裝會移除外掛代碼和本地 KV 資料,保留使用者設定、秘密儲存和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "狀態不明",
"plugins.sidebar.extensions": "擴充功能頁面",
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
@@ -1315,44 +1315,44 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
"settings.plugins.themes.failed": "無法套用主題。",
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權存取通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 檔案系統和網路能力,代理權限無法限制這些存取。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此裝置。只有明確標記為同步的非秘密設置進入加密雲 sidecar。外掛包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.openManager": "開啟外掛管理",
"settings.plugins.sources.conflict": "此 ID 已從其他來源安裝。請先解除安裝並明確清理舊來源資料,再換源重新安裝。原來源:",
"settings.plugins.sources.clearPrivate": "同時清除本來源的私有資料、授權和秘密,以便明確換源重新安裝。此操作無法復原。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本機",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
"settings.plugins.sources.official": "官方市集",
"settings.plugins.sources.thirdparty": "第三方市集",
"settings.plugins.sources.manage": "管理來源",
"settings.plugins.sources.select": "選擇來源",
"settings.plugins.sources.empty": "選擇或新增第三方來源。",
"settings.plugins.sources.add": "新增來源",
"settings.plugins.sources.remove": "移除來源",
"settings.plugins.sources.name": "來源名稱",
"settings.plugins.sources.address": "HTTPS 來源位址",
"settings.plugins.sources.risk": "第三方來源不代表 Netcatty 維護或認可。安裝前請核對真實網域、外掛權限與作者。",
"settings.plugins.sources.accept": "我信任此網域提供外掛目錄和套件。此操作不會授予外掛權限。",
"settings.plugins.sources.removeNote": "保留已安裝外掛及資料,停止此來源更新,不會自動切換到其他來源。",
"settings.plugins.sources.disablePlugins": "同時停用來自該來源的外掛",
"settings.plugins.sources.installConfirm": "確認第三方安裝",
"settings.plugins.sources.source": "來源",
"settings.plugins.sources.local": "本機匯入",
"settings.plugins.sources.all": "全部來源",
"settings.plugins.title": "外掛",
"settings.plugins.configuration": "外掛設定",
"settings.plugins.description": "管理已安裝外掛,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.market.results": "{count} 個外掛",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市集安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.status": "{count} 項提醒",
@@ -1362,22 +1362,22 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.market.readmeInfo": "README 來源說明",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.title": "外掛市集",
"settings.plugins.market.note": "精選開源外掛。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有外掛",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並匯入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠端圖片不載入。內容由外掛作者提供。",
"settings.plugins.manager.noVault": "系統金鑰儲存不可用。秘密和憑據操作被阻止;瀏覽市集和不使用秘密的外掛仍可使用。",
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.manager.dual": "預設使用瀏覽器運行時,但包中還包含擁有完整裝置權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器外掛模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.empty": "目錄暫無外掛,仍可匯入本地瀏覽器外掛。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.noResults": "沒有匹配外掛,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
+18 -18
View File
@@ -4,10 +4,10 @@ export const zhTwSystemManagerMessages: Messages = {
'terminal.layer.system': '系統',
'systemManager.noSession': '沒有使用中的終端工作階段。',
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
'systemManager.notConnected': '請先連線到主機以管理行程與服務。',
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.processes': '行程',
'systemManager.tabs.ports': '連接埠',
'systemManager.tabs.services': '服務',
'systemManager.tabs.tmux': 'tmux',
@@ -17,7 +17,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
'systemManager.errors.loadProcesses': '載入程序列表失敗',
'systemManager.errors.loadProcesses': '載入行程列表失敗',
'systemManager.errors.loadTmux': '載入 tmux 工作階段失敗',
'systemManager.errors.loadTmuxWindows': '載入 tmux 視窗失敗',
'systemManager.errors.loadTmuxPanes': '載入 tmux 窗格失敗',
@@ -58,13 +58,13 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體佔用最高程序',
'systemManager.overview.topProcesses': '記憶體佔用最高行程',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網路卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.overview.noTopProcesses': '暫無行程資料',
'systemManager.processes.search': '搜尋程序…',
'systemManager.processes.search': '搜尋行程…',
'systemManager.processes.command': '指令',
'systemManager.processes.user': '使用者',
'systemManager.processes.term': '終止',
@@ -75,18 +75,18 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.processes.renice': '調整優先順序',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必須在 -20 到 19 之間',
'systemManager.processes.confirmKill': '向程序 {{pid}} 傳送 SIGKILL?',
'systemManager.processes.confirmSignal': '向程序 {{pid}} 傳送 SIG{{signal}}?',
'systemManager.processes.confirmKill': '向行程 {{pid}} 傳送 SIGKILL?',
'systemManager.processes.confirmSignal': '向行程 {{pid}} 傳送 SIG{{signal}}?',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '執行中',
'systemManager.processes.ppid': '父程序 PID',
'systemManager.processes.ppid': '父行程 PID',
'systemManager.processes.rss': '實體記憶體',
'systemManager.processes.vsz': '虛擬記憶體',
'systemManager.processes.elapsed': '執行時長',
'systemManager.processes.stat': '狀態',
'systemManager.processes.meta': '{{count}} 個程序',
'systemManager.processes.loading': '正在載入程序…',
'systemManager.processes.loadingMore': '正在載入更多程序…',
'systemManager.processes.meta': '{{count}} 個行程',
'systemManager.processes.loading': '正在載入行程…',
'systemManager.processes.loadingMore': '正在載入更多行程…',
'systemManager.processes.state.running': '執行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暫停',
@@ -236,9 +236,9 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.ports.search': '搜尋連接埠…',
'systemManager.ports.meta': '{{count}} 個監聽',
'systemManager.ports.filter.all': '全部',
'systemManager.ports.unknownProcess': '未知程序',
'systemManager.ports.terminate': '結束程序',
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
'systemManager.ports.unknownProcess': '未知行程',
'systemManager.ports.terminate': '結束行程',
'systemManager.ports.confirmTerminate': '向佔用該連接埠的行程 {{pid}} 傳送 SIGTERM?',
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
'systemManager.services.loading': '正在載入 systemd 服務…',
@@ -261,10 +261,10 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個行程',
'systemManager.gpu.devices': '裝置',
'systemManager.gpu.processes': '計算程序',
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
'systemManager.gpu.processes': '計算行程',
'systemManager.gpu.noProcesses': '目前沒有計算行程。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇騰',
'systemManager.gpu.util': '使用率',
+3 -3
View File
@@ -510,8 +510,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.serverStats.refreshInterval.desc': '伺服器狀態重新整理的頻率。',
'settings.terminal.serverStats.seconds': '秒',
'settings.terminal.section.systemManager': '系統管理',
'settings.terminal.systemManager.processRefreshInterval': '程序列表重新整理間隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
'settings.terminal.systemManager.processRefreshInterval': '行程列表重新整理間隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中行程列表的重新整理頻率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
@@ -969,7 +969,7 @@ export const zhTWTerminalMessages: Messages = {
'snippets.variables.placeholderDefault': '預設:{value}',
'snippets.variables.required': '請填寫此變數',
'snippets.variables.run': '執行',
'snippets.field.variablesHelp': '在指令碼中使用 {{名稱}} 或 {{名稱:預設值}} 定義變數。',
'snippets.field.variablesHelp': '在指令碼中使用 {{name}} 或 {{name:default}} 作為佔位符。',
'snippets.field.variablesDetected': '變數',
'snippets.field.variableDefault': '預設 {value}',
+1 -1
View File
@@ -580,7 +580,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.serverStats.swapUsed': '已用交換',
'terminal.serverStats.swapFree': '空閒交換',
'terminal.serverStats.swapTotal': '總計',
'terminal.serverStats.topProcesses': '記憶體佔用前十程序',
'terminal.serverStats.topProcesses': '記憶體佔用前十行程',
'terminal.serverStats.disk': '磁碟使用',
'terminal.serverStats.diskDetails': '已掛載磁碟',
'terminal.serverStats.network': '網路速度',
@@ -0,0 +1,57 @@
import { useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
const closeCaptures = new Map<string, Set<() => void>>();
/** Notify owning terminals before explicit session removal, never on runtime cleanup. */
export function captureClosingTerminalSessions(sessionIds: readonly string[]): void {
for (const sessionId of new Set(sessionIds)) {
for (const capture of closeCaptures.get(sessionId) ?? []) capture();
}
}
export function useTerminalSessionCloseCapture({
sessionId,
enabled,
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture,
}: {
sessionId: string;
enabled: boolean;
hasConnectedRef: RefObject<boolean>;
terminalDataCapturedRef: RefObject<boolean>;
finalizeTerminalLogData: () => string;
onTerminalDataCapture: (
sessionId: string,
data: string,
options: { finalized: true },
) => void | Promise<void>;
}): void {
useEffect(() => {
if (!enabled) return;
const capture = () => {
// Connected terminals retain their existing drain/serialize close path.
if (hasConnectedRef.current || terminalDataCapturedRef.current) return;
try {
// A never-connected runtime is disposed synchronously on unmount.
// Finish its log now, even if empty, so persistence can set endTime.
void Promise.resolve(onTerminalDataCapture(
sessionId, finalizeTerminalLogData(), { finalized: true },
)).catch((err: unknown) => {
logger.warn('Failed to capture terminal log on session close', err);
});
} catch (err) {
logger.warn('Failed to capture terminal log on session close', err);
}
};
const captures = closeCaptures.get(sessionId) ?? new Set<() => void>();
closeCaptures.set(sessionId, captures);
captures.add(capture);
return () => {
captures.delete(capture);
if (!captures.size) closeCaptures.delete(sessionId);
};
}, [sessionId, enabled, hasConnectedRef, terminalDataCapturedRef, finalizeTerminalLogData, onTerminalDataCapture]);
}
+3
View File
@@ -75,6 +75,7 @@ import {
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { captureClosingTerminalSessions } from './terminalSessionCloseCapture';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
@@ -603,6 +604,7 @@ export const useSessionState = ({
const closedIds = sessionsRef.current
.filter(session => session.workspaceId === workspaceId)
.map(session => session.id);
captureClosingTerminalSessions(closedIds);
cleanupClosedTerminalSessions(closedIds);
for (const sessionId of closedIds) {
sessionPresentationStore.clearSession(sessionId);
@@ -626,6 +628,7 @@ export const useSessionState = ({
}, [setActiveTabId]);
const closeSessions = useCallback((sessionIds: string[]) => {
captureClosingTerminalSessions(sessionIds);
cleanupClosedTerminalSessions(sessionIds);
for (const sessionId of sessionIds) {
sessionPresentationStore.clearSession(sessionId);
+1
View File
@@ -409,6 +409,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
const nestedDelayMs = (request.aiSubTab || request.syncSubTab) ? 80 : 40;
const focusHandle = window.setTimeout(() => {
void focusSettingsAnchor(request.anchorId, {
fallbackAnchorId: request.fallbackAnchorId,
attempts: 48,
delayMs: 50,
}).finally(() => {
+23
View File
@@ -82,6 +82,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalSessionCloseCapture } from "../application/state/terminalSessionCloseCapture";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
import {
@@ -1676,6 +1677,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
useTerminalSessionCloseCapture({
sessionId,
enabled: !attachExistingSession && restoreState !== "restored-disconnected",
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
});
useEffect(() => {
// Attached popups do not own the ConnectionLog; the home terminal captures it.
if (attachExistingSession) return;
return netcattyBridge.get()?.onFinalizeSessionLogs?.(async () => {
if (terminalDataCapturedRef.current || (restoreState === "restored-disconnected" && statusRef.current === "disconnected")) return;
const term = termRef.current;
if (term && !await flushPendingTerminalWritesBeforeHibernate(term)) {
throw new Error("Terminal output did not drain before quit capture");
}
await handleTerminalDataCaptureOnce(sessionId, finalizeTerminalLogData(), { finalized: true });
});
}, [attachExistingSession, restoreState, sessionId, finalizeTerminalLogData, handleTerminalDataCaptureOnce]);
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
useEffect(() => {
@@ -93,6 +93,7 @@ export function SettingsSearchControl({
aiSubTab: hit.entry.aiSubTab,
syncSubTab: hit.entry.syncSubTab,
anchorId: hit.entry.id,
fallbackAnchorId: hit.entry.fallbackAnchorId,
});
collapseSearch(false);
};
+97 -1
View File
@@ -1,6 +1,102 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import test, { type TestContext } from "node:test";
import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog.ts";
import { focusSettingsAnchor } from "./settingsFocus.ts";
function setupFocusDom(t: TestContext) {
t.mock.timers.enable({ apis: ["setTimeout"] });
const anchors = new Map<string, HTMLElement>();
const focused: string[] = [];
const scrolled: string[] = [];
const globals = {
document: {
getElementById: (id: string) => anchors.get(id) ?? null,
querySelectorAll: () => [],
documentElement: {},
body: {},
},
window: { setTimeout, clearTimeout },
CSS: { escape: (value: string) => value },
requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 0; },
};
for (const [key, value] of Object.entries(globals)) {
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, { configurable: true, value });
t.after(() => {
if (previous) Object.defineProperty(globalThis, key, previous);
else Reflect.deleteProperty(globalThis, key);
});
}
const mount = (id: string, visible = true) => {
const classes = new Set<string>();
const el = {
closest: () => null,
checkVisibility: () => visible,
classList: { add: (name: string) => classes.add(name), remove: (name: string) => classes.delete(name) },
matches: () => true,
hasAttribute: () => true,
focus: () => focused.push(id),
scrollIntoView: () => scrolled.push(id),
} as unknown as HTMLElement;
anchors.set(settingsAnchorDomId(id), el);
return classes;
};
return { mount, focused, scrolled };
}
test("missing search target focuses and highlights its visible parent after a short mount window", async (t) => {
const { mount, focused, scrolled } = setupFocusDom(t);
const parentClasses = mount("parent");
const result = focusSettingsAnchor("child", { fallbackAnchorId: "parent", attempts: 48, delayMs: 50 });
for (let retry = 0; retry < 3; retry += 1) t.mock.timers.tick(50);
t.mock.timers.tick(49);
assert.deepEqual(focused, []);
t.mock.timers.tick(1);
assert.equal(await result, true);
assert.deepEqual(focused, ["parent"]);
assert.deepEqual(scrolled, ["parent"]);
assert.ok(parentClasses.has("settings-search-highlight"));
});
test("search focus prefers a visible primary over its fallback", async (t) => {
const { mount, focused } = setupFocusDom(t);
const childClasses = mount("child");
const parentClasses = mount("parent");
assert.equal(await focusSettingsAnchor("child", { fallbackAnchorId: "parent" }), true);
assert.deepEqual(focused, ["child"]);
assert.ok(childClasses.has("settings-search-highlight"));
assert.equal(parentClasses.has("settings-search-highlight"), false);
});
test("search focus waits for a lazy primary before using a visible fallback", async (t) => {
const { mount, focused } = setupFocusDom(t);
mount("parent");
const result = focusSettingsAnchor("child", { fallbackAnchorId: "parent" });
t.mock.timers.tick(50);
t.mock.timers.tick(50);
mount("child");
t.mock.timers.tick(50);
assert.equal(await result, true);
assert.deepEqual(focused, ["child"]);
});
test("search focus skips a hidden primary and waits for a visible fallback", async (t) => {
const { mount, focused } = setupFocusDom(t);
mount("child", false);
mount("parent", false);
const result = focusSettingsAnchor("child", { fallbackAnchorId: "parent" });
for (let retry = 0; retry < 4; retry += 1) t.mock.timers.tick(50);
assert.deepEqual(focused, []);
mount("parent");
t.mock.timers.tick(50);
assert.equal(await result, true);
assert.deepEqual(focused, ["parent"]);
});
test("system setting hints sit below their cards with a shared top gap", () => {
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
+24 -15
View File
@@ -4,12 +4,15 @@ const HIGHLIGHT_CLASS = "settings-search-highlight";
const HIGHLIGHT_MS = 1600;
/** Keep focused anchors slightly below the top of the settings content pane. */
const SCROLL_TOP_PADDING_PX = 24;
/** Allow 200ms at the default retry delay for lazy tab / sub-tab mounts. */
const FALLBACK_AFTER_ATTEMPTS = 5;
export type SettingsFocusTarget = {
tab: string;
aiSubTab?: string;
syncSubTab?: string;
anchorId: string;
fallbackAnchorId?: string;
};
let focusGeneration = 0;
@@ -30,6 +33,18 @@ function isAnchorVisible(el: HTMLElement): boolean {
return el.getClientRects().length > 0;
}
function findVisibleAnchor(anchorId: string): HTMLElement | undefined {
const candidates = [
document.getElementById(settingsAnchorDomId(anchorId)),
...Array.from(
document.querySelectorAll<HTMLElement>(
`[data-settings-anchor="${CSS.escape(anchorId)}"]`,
),
),
].filter((node): node is HTMLElement => Boolean(node));
return candidates.find((node) => isAnchorVisible(node));
}
function focusAnchorElement(el: HTMLElement): void {
const focusable = el.matches("button, a, input, select, textarea, [tabindex]")
? el
@@ -103,15 +118,16 @@ export function scrollSettingsAnchorIntoView(
/**
* Scroll the settings content pane to a catalog anchor and briefly highlight it.
* Retries to cover lazy tab mounts / nested sub-tab switches.
* After a short mount window, use a visible parent fallback for gated rows.
*/
export function focusSettingsAnchor(
anchorId: string,
options?: { attempts?: number; delayMs?: number },
options?: { attempts?: number; delayMs?: number; fallbackAnchorId?: string },
): Promise<boolean> {
const attempts = options?.attempts ?? 40;
const delayMs = options?.delayMs ?? 50;
const generation = ++focusGeneration;
let remaining = attempts;
let attempt = 0;
return new Promise((resolve) => {
const finish = (ok: boolean) => {
@@ -125,20 +141,13 @@ export function focusSettingsAnchor(
return;
}
const domId = settingsAnchorDomId(anchorId);
const candidates = [
document.getElementById(domId),
...Array.from(
document.querySelectorAll<HTMLElement>(
`[data-settings-anchor="${CSS.escape(anchorId)}"]`,
),
),
].filter((node): node is HTMLElement => Boolean(node));
const el = candidates.find((node) => isAnchorVisible(node));
attempt += 1;
let el = findVisibleAnchor(anchorId);
if (!el && options?.fallbackAnchorId && attempt >= Math.min(FALLBACK_AFTER_ATTEMPTS, attempts)) {
el = findVisibleAnchor(options.fallbackAnchorId);
}
if (!el) {
remaining -= 1;
if (remaining > 0) {
if (attempt < attempts) {
window.setTimeout(tryFocus, delayMs);
return;
}
@@ -258,6 +258,7 @@ export const AppLockSettingsSection: React.FC<AppLockSettingsSectionProps> = ({
</SettingRow>
<SettingRow
anchorId="system-app-lock-timeout"
label={t('settings.appLock.timeout')}
description={t('settings.appLock.timeoutDesc')}
>
@@ -66,9 +66,15 @@ test('local logging controls default off, disclose verbose risk, and call host-o
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
// Radix restores focus in a setTimeout(0) after the dialog unmounts.
// act() can finish before that timer, so drain it before checking focus.
await new Promise((resolve) => setTimeout(resolve, 0));
// Comparing DOM objects directly makes Node inspect the entire JSDOM graph
// on failure, which can stall the test process while formatting the diff.
assert.ok(document.activeElement === configure, `expected focus on Configure, got ${document.activeElement?.tagName}`);
} finally {
await act(async () => root.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -693,6 +693,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{httpNetworkProxy.mode === "custom" && (
<>
<SettingRow
anchorId="system-network-proxy-url"
label={t("settings.system.networkProxy.url")}
description={t("settings.system.networkProxy.url.desc")}
>
@@ -710,6 +711,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
/>
</SettingRow>
<SettingRow
anchorId="system-network-proxy-bypass"
label={t("settings.system.networkProxy.bypass")}
description={t("settings.system.networkProxy.bypass.desc")}
>
@@ -1088,7 +1090,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</SettingRow>
{/* Directory Selection */}
<div className="space-y-2">
<SettingsAnchor anchorId="system-session-logs-directory" className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
</div>
@@ -1125,10 +1127,11 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.directoryHint")}
</p>
</div>
</SettingsAnchor>
{/* Format Selection */}
<SettingRow
anchorId="system-session-logs-format"
label={t("settings.sessionLogs.format")}
description={t("settings.sessionLogs.formatDesc")}
>
@@ -1141,6 +1144,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</SettingRow>
<SettingRow
anchorId="system-session-logs-timestamps"
label={t("settings.sessionLogs.timestamps")}
description={t("settings.sessionLogs.timestampsDesc")}
>
@@ -814,6 +814,7 @@ function SettingsTerminalTab(props: {
</SettingRow>
<SettingRow
anchorId="terminal-side-panel-auto-open-pane"
label={t("settings.terminal.sidePanel.autoOpenPane")}
description={t("settings.terminal.sidePanel.autoOpenPane.desc")}
>
@@ -841,6 +842,7 @@ function SettingsTerminalTab(props: {
</SettingRow>
<SettingRow
anchorId="terminal-local-shell-side-panel-auto-open-pane"
label={t("settings.terminal.localShellSidePanel.autoOpenPane")}
description={t("settings.terminal.localShellSidePanel.autoOpenPane.desc")}
>
@@ -945,6 +947,7 @@ function SettingsTerminalTab(props: {
</SettingRow>
<SettingRow
anchorId="terminal-local-shell-start-dir"
label={t("settings.terminal.localShell.startDir")}
description={t("settings.terminal.localShell.startDir.desc")}
>
@@ -1048,6 +1051,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-keepalive-count-max"
label={t("settings.terminal.connection.keepaliveCountMax")}
description={t("settings.terminal.connection.keepaliveCountMax.desc")}
>
@@ -1082,6 +1086,7 @@ function SettingsTerminalTab(props: {
<SectionHeader title={t("settings.terminal.section.serverStats")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="terminal-host-info-bar-show"
label={t("settings.terminal.hostInfoBar.show")}
description={t("settings.terminal.hostInfoBar.show.desc")}
>
@@ -1092,6 +1097,7 @@ function SettingsTerminalTab(props: {
</SettingRow>
{terminalSettings.showHostInfoBar && (
<SettingRow
anchorId="terminal-host-info-bar-title-mode"
label={t("settings.terminal.hostInfoBar.titleMode")}
description={t("settings.terminal.hostInfoBar.titleMode.desc")}
>
@@ -1119,6 +1125,7 @@ function SettingsTerminalTab(props: {
{terminalSettings.showServerStats && (
<SettingRow
anchorId="terminal-server-stats-refresh-interval"
label={t("settings.terminal.serverStats.refreshInterval")}
description={t("settings.terminal.serverStats.refreshInterval.desc")}
>
@@ -1145,6 +1152,7 @@ function SettingsTerminalTab(props: {
<SectionHeader title={t("settings.terminal.section.systemManager")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="terminal-system-manager-process-refresh-interval"
label={t("settings.terminal.systemManager.processRefreshInterval")}
description={t("settings.terminal.systemManager.processRefreshInterval.desc")}
>
@@ -1166,6 +1174,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-system-manager-tmux-refresh-interval"
label={t("settings.terminal.systemManager.tmuxRefreshInterval")}
description={t("settings.terminal.systemManager.tmuxRefreshInterval.desc")}
>
@@ -1187,6 +1196,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-system-manager-docker-list-refresh-interval"
label={t("settings.terminal.systemManager.dockerListRefreshInterval")}
description={t("settings.terminal.systemManager.dockerListRefreshInterval.desc")}
>
@@ -1208,6 +1218,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-system-manager-docker-stats-refresh-interval"
label={t("settings.terminal.systemManager.dockerStatsRefreshInterval")}
description={t("settings.terminal.systemManager.dockerStatsRefreshInterval.desc")}
>
@@ -1249,6 +1260,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-hibernate-hidden-tabs"
label={t("settings.terminal.rendering.hibernateHiddenTabs")}
description={t("settings.terminal.rendering.hibernateHiddenTabs.desc")}
>
@@ -1260,6 +1272,7 @@ function SettingsTerminalTab(props: {
{terminalSettings.hibernateHiddenTabs && (
<>
<SettingRow
anchorId="terminal-hibernate-hidden-tabs-delay"
label={t("settings.terminal.rendering.hibernateHiddenTabsDelay")}
description={t("settings.terminal.rendering.hibernateHiddenTabsDelay.desc")}
>
@@ -1281,6 +1294,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-hibernate-skip-alt-screen"
label={t("settings.terminal.rendering.hibernateSkipAltScreen")}
description={t("settings.terminal.rendering.hibernateSkipAltScreen.desc")}
>
@@ -1290,6 +1304,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-hibernate-keep-renderer-count"
label={t("settings.terminal.rendering.hibernateKeepRendererCount")}
description={t("settings.terminal.rendering.hibernateKeepRendererCount.desc")}
>
@@ -1308,6 +1323,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-hibernate-replay-chunk-bytes"
label={t("settings.terminal.rendering.hibernateReplayChunkBytes")}
description={t("settings.terminal.rendering.hibernateReplayChunkBytes.desc")}
>
@@ -1327,6 +1343,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-hibernate-prefer-wasm-serialize"
label={t("settings.terminal.rendering.hibernatePreferWasmSerialize")}
description={t("settings.terminal.rendering.hibernatePreferWasmSerialize.desc")}
>
@@ -1354,6 +1371,7 @@ function SettingsTerminalTab(props: {
{terminalSettings.inlineImagesEnabled && (
<>
<SettingRow
anchorId="terminal-inline-images-kitty"
label={t("settings.terminal.inlineImages.kitty")}
description={t("settings.terminal.inlineImages.kitty.desc")}
>
@@ -1363,6 +1381,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-inline-images-sixel"
label={t("settings.terminal.inlineImages.sixel")}
description={t("settings.terminal.inlineImages.sixel.desc")}
>
@@ -1372,6 +1391,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-inline-images-iip"
label={t("settings.terminal.inlineImages.iip")}
description={t("settings.terminal.inlineImages.iip.desc")}
>
@@ -1381,6 +1401,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-inline-images-storage-limit"
label={t("settings.terminal.inlineImages.storageLimit")}
description={t("settings.terminal.inlineImages.storageLimit.desc")}
>
@@ -1406,6 +1427,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-inline-images-max-megapixels"
label={t("settings.terminal.inlineImages.maxMegapixels")}
description={t("settings.terminal.inlineImages.maxMegapixels.desc")}
>
@@ -1431,6 +1453,7 @@ function SettingsTerminalTab(props: {
</div>
</SettingRow>
<SettingRow
anchorId="terminal-inline-images-sequence-limit"
label={t("settings.terminal.inlineImages.sequenceLimit")}
description={t("settings.terminal.inlineImages.sequenceLimit.desc")}
>
@@ -1496,6 +1519,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-autocomplete-ghost-text"
label={t("settings.terminal.autocomplete.ghostText")}
description={t("settings.terminal.autocomplete.ghostText.desc")}
>
@@ -1506,6 +1530,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-autocomplete-popup-menu"
label={t("settings.terminal.autocomplete.popupMenu")}
description={t("settings.terminal.autocomplete.popupMenu.desc")}
>
@@ -1516,6 +1541,7 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-autocomplete-history-scope"
label={t("settings.terminal.autocomplete.historyScope")}
description={t("settings.terminal.autocomplete.historyScope.desc")}
>
@@ -101,6 +101,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-right-click-long-press-menu"
label={t("settings.terminal.behavior.rightClick.longPressMenu")}
description={t("settings.terminal.behavior.rightClick.longPressMenu.desc")}
>
@@ -111,6 +112,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-right-click-fullscreen-menu"
label={t("settings.terminal.behavior.rightClick.fullscreenMenu")}
description={t("settings.terminal.behavior.rightClick.fullscreenMenu.desc")}
>
@@ -228,6 +230,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-shift-enter-newline-text"
label={t("settings.terminal.behavior.shiftEnterNewlineText")}
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
>
@@ -260,6 +263,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-preserve-selection-on-input"
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
>
@@ -267,6 +271,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-force-prompt-new-line"
label={t("settings.terminal.behavior.forcePromptNewLine")}
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
>
@@ -341,6 +346,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-scroll-on-input"
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
>
@@ -348,6 +354,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-scroll-on-output"
label={t("settings.terminal.behavior.scrollOnOutput")}
description={t("settings.terminal.behavior.scrollOnOutput.desc")}
>
@@ -355,6 +362,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-scroll-on-key-press"
label={t("settings.terminal.behavior.scrollOnKeyPress")}
description={t("settings.terminal.behavior.scrollOnKeyPress.desc")}
>
@@ -362,6 +370,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-scroll-on-paste"
label={t("settings.terminal.behavior.scrollOnPaste")}
description={t("settings.terminal.behavior.scrollOnPaste.desc")}
>
@@ -369,6 +378,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-smooth-scrolling"
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
@@ -392,6 +402,7 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
anchorId="terminal-link-modifier"
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}
>
@@ -756,7 +756,7 @@ export const ExternalMcpCard: React.FC = () => {
</div>
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
<SettingRow anchorId="ai-external-mcp-behavior" className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
<SettingCard divided className="border-0 bg-transparent p-0">
<SettingRow
@@ -879,7 +879,7 @@ export const ExternalMcpCard: React.FC = () => {
/>
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
<SettingRow anchorId="ai-external-mcp-client-configuration" className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
<div className="space-y-2">
@@ -136,6 +136,7 @@ export const SafetySettings: React.FC<{
</SettingRow>
<SettingRow
anchorId="ai-safety-max-iterations"
label={t('ai.safety.maxIterations')}
description={t('ai.safety.maxIterations.description')}
>
@@ -144,6 +144,7 @@ export const WebSearchSettings: React.FC<{
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
anchorId="ai-web-search-api-key"
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
@@ -170,6 +171,7 @@ export const WebSearchSettings: React.FC<{
{/* API Host */}
<SettingRow
anchorId="ai-web-search-api-host"
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
@@ -188,6 +190,7 @@ export const WebSearchSettings: React.FC<{
{/* Max Results */}
<SettingRow
anchorId="ai-web-search-max-results"
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
+3 -3
View File
@@ -938,9 +938,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Cancel the pending backend boot after giving an immediate
// Never-connected logs are captured at explicit session close, including
// empty buffers. Runtime cleanup alone must not end an open log.
// Cancel the pending backend boot after giving an immediate
// remount a chance to replace this generation, instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session (skip closeSession) but still sync-dispose xterm.
+94
View File
@@ -64,6 +64,41 @@ test("settings search catalog has unique ids", () => {
assert.equal(new Set(ids).size, ids.length);
});
test("newly indexed gated settings retain visible parent destinations in search", () => {
const fallbacks = {
"terminal-hibernate-hidden-tabs-delay": "terminal-hibernate-hidden-tabs",
"terminal-hibernate-skip-alt-screen": "terminal-hibernate-hidden-tabs",
"terminal-hibernate-keep-renderer-count": "terminal-hibernate-hidden-tabs",
"terminal-hibernate-replay-chunk-bytes": "terminal-hibernate-hidden-tabs",
"terminal-hibernate-prefer-wasm-serialize": "terminal-hibernate-hidden-tabs",
"terminal-inline-images-kitty": "terminal-inline-images-enabled",
"terminal-inline-images-sixel": "terminal-inline-images-enabled",
"terminal-inline-images-iip": "terminal-inline-images-enabled",
"terminal-inline-images-storage-limit": "terminal-inline-images-enabled",
"terminal-inline-images-max-megapixels": "terminal-inline-images-enabled",
"terminal-inline-images-sequence-limit": "terminal-inline-images-enabled",
"terminal-host-info-bar-title-mode": "terminal-host-info-bar-show",
"terminal-server-stats-refresh-interval": "terminal-server-stats-show",
"ai-web-search-api-key": "ai-web-search-provider",
"system-network-proxy-url": "system-network-proxy-mode",
"system-network-proxy-bypass": "system-network-proxy-mode",
"system-app-lock-timeout": "system-app-lock",
};
const hits = filterSettingsSearchCatalog("", (key) => en[key] ?? key, { limit: 500 });
for (const [id, fallbackId] of Object.entries(fallbacks)) {
const entry = hits.find((hit) => hit.entry.id === id)?.entry;
assert.ok(entry, id);
assert.equal(entry.fallbackAnchorId, fallbackId, id);
const parent = SETTINGS_SEARCH_CATALOG.find((candidate) => candidate.id === fallbackId);
assert.ok(parent, fallbackId);
assert.equal(parent.tab, entry.tab, id);
assert.equal(parent.aiSubTab, entry.aiSubTab, id);
assert.equal(parent.fallbackAnchorId, undefined, fallbackId);
}
// The local start-directory row is always mounted in this UI.
assert.equal(hits.find((hit) => hit.entry.id === "terminal-local-shell-start-dir")?.entry.fallbackAnchorId, undefined);
});
test("filterSettingsSearchCatalog matches English labels", () => {
const hits = filterSettingsSearchCatalog("copy on select", tEn);
assert.ok(hits.some((hit) => hit.entry.id === "terminal-copy-on-select"));
@@ -167,6 +202,65 @@ test("settings search finds SSH connection keep-alive from the real locales", ()
}
});
test("settings search finds note typography from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [id, zhQuery, enQuery] of [
["appearance-vault-notes-font", "代码字体", "code font"],
["appearance-vault-notes-font-size", "笔记字号", "note font size"],
["appearance-vault-notes-code-font-size", "代码字号", "code font size"],
] as const) {
for (const [t, query] of [[tZhReal, zhQuery], [tEnReal, enQuery]] as const) {
const hit = filterSettingsSearchCatalog(query, t).find((hit) => hit.entry.id === id);
assert.ok(hit, `${query} should find ${id}`);
assert.equal(hit.entry.tab, "appearance");
}
}
});
test("settings search finds session log options from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [id, t, queries] of [
["system-session-logs-directory", tZhReal, ["保存目录"]],
["system-session-logs-directory", tEnReal, ["save directory"]],
["system-session-logs-format", tZhReal, ["日志格式"]],
["system-session-logs-format", tEnReal, ["log format"]],
["system-session-logs-timestamps", tZhReal, ["时间戳"]],
["system-session-logs-timestamps", tEnReal, ["timestamp", "Add timestamps"]],
] as const) {
for (const query of queries) {
const hit = filterSettingsSearchCatalog(query, t).find((hit) => hit.entry.id === id);
assert.ok(hit, `${query} should find ${id}`);
assert.equal(hit.entry.tab, "system");
assert.equal(hit.entry.sectionKey, "settings.sessionLogs.title");
}
}
});
test("later-added settings retain their tab and sub-tab destinations", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [id, tab, subTab, labelKey] of [
["terminal-font-ligatures", "terminal", undefined, "settings.terminal.font.ligatures"],
["terminal-local-shell-side-panel-auto-open", "terminal", undefined, "settings.terminal.localShellSidePanel.autoOpen"],
["terminal-system-manager-process-refresh-interval", "terminal", undefined, "settings.terminal.systemManager.processRefreshInterval"],
["ai-chat-history-message-cap", "ai", "tools", "ai.chatHistory.messageCap"],
["ai-web-search-max-results", "ai", "search", "ai.webSearch.maxResults"],
["ai-safety-max-iterations", "ai", "safety", "ai.safety.maxIterations"],
["sync-ai-sessions", "sync", "status", "cloudSync.aiSessions.title"],
["system-network-proxy-url", "system", undefined, "settings.system.networkProxy.url"],
] as const) {
for (const t of [tZhReal, tEnReal]) {
assert.notEqual(t(labelKey), labelKey);
const hit = filterSettingsSearchCatalog(t(labelKey), t).find((hit) => hit.entry.id === id);
assert.ok(hit, `${t(labelKey)} should find ${id}`);
assert.equal(hit.entry.tab, tab);
assert.equal(hit.entry.aiSubTab ?? hit.entry.syncSubTab, subTab);
}
}
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
+2 -2
View File
@@ -24,7 +24,7 @@ function collectSourceFiles(root: string): string[] {
return out;
}
test("every settings search catalog id is wired as an anchor in components", () => {
test("every settings search catalog id and fallback is wired as an anchor in components", () => {
const roots = [
join(process.cwd(), "components"),
];
@@ -32,7 +32,7 @@ test("every settings search catalog id is wired as an anchor in components", ()
const haystack = sources.map((file) => readFileSync(file, "utf8")).join("\n");
const missing = SETTINGS_SEARCH_CATALOG
.map((entry) => entry.id)
.flatMap((entry) => [entry.id, ...(entry.fallbackAnchorId ? [entry.fallbackAnchorId] : [])])
.filter((id) => {
const asProp = `anchorId="${id}"`;
const asPropSingle = `anchorId='${id}'`;
+441
View File
@@ -36,6 +36,11 @@ export type SettingsPlatform = "mac" | "win" | "linux";
export type SettingsSearchEntry = {
/** Stable id; also used as `data-settings-anchor` on the target element. */
id: string;
/**
* Already-mounted parent to highlight when a prerequisite setting is off and
* this entry's own anchor is not in the DOM.
*/
fallbackAnchorId?: string;
tab: SettingsTabId;
/** Nested AI settings tab when `tab === "ai"`. */
aiSubTab?: AISettingsSubTabId;
@@ -214,6 +219,27 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.vault.autoImportSystemKnownHostsDesc",
sectionKey: "settings.vault.title",
},
{
id: "appearance-vault-notes-font",
tab: "appearance",
labelKey: "settings.vault.notesFont",
descriptionKey: "settings.vault.notesFontDesc",
sectionKey: "settings.vault.title",
},
{
id: "appearance-vault-notes-font-size",
tab: "appearance",
labelKey: "settings.vault.notesFontSize",
descriptionKey: "settings.vault.notesFontSizeDesc",
sectionKey: "settings.vault.title",
},
{
id: "appearance-vault-notes-code-font-size",
tab: "appearance",
labelKey: "settings.vault.notesCodeFontSize",
descriptionKey: "settings.vault.notesCodeFontSizeDesc",
sectionKey: "settings.vault.title",
},
{
id: "appearance-custom-css",
tab: "appearance",
@@ -273,6 +299,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.font.smoothing.desc",
sectionKey: "settings.terminal.section.font",
},
{
id: "terminal-font-ligatures",
tab: "terminal",
labelKey: "settings.terminal.font.ligatures",
descriptionKey: "settings.terminal.font.ligatures.desc",
sectionKey: "settings.terminal.section.font",
},
{
id: "terminal-font-line-padding",
tab: "terminal",
@@ -366,6 +399,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.rightClick.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-right-click-long-press-menu",
tab: "terminal",
labelKey: "settings.terminal.behavior.rightClick.longPressMenu",
descriptionKey: "settings.terminal.behavior.rightClick.longPressMenu.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-right-click-fullscreen-menu",
tab: "terminal",
labelKey: "settings.terminal.behavior.rightClick.fullscreenMenu",
descriptionKey: "settings.terminal.behavior.rightClick.fullscreenMenu.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-copy-on-select",
tab: "terminal",
@@ -415,6 +462,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.confirmMultilinePaste.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-confirm-multiline-paste-min-lines",
tab: "terminal",
labelKey: "settings.terminal.behavior.confirmMultilinePasteMinLines",
descriptionKey: "settings.terminal.behavior.confirmMultilinePasteMinLines.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-shift-enter-newline",
tab: "terminal",
@@ -422,6 +476,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.shiftEnterNewline.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-shift-enter-newline-text",
tab: "terminal",
labelKey: "settings.terminal.behavior.shiftEnterNewlineText",
descriptionKey: "settings.terminal.behavior.shiftEnterNewlineText.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-shift-enter-force-text",
tab: "terminal",
@@ -443,6 +504,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.clearWipesScrollback.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-preserve-selection-on-input",
tab: "terminal",
labelKey: "settings.terminal.behavior.preserveSelectionOnInput",
descriptionKey: "settings.terminal.behavior.preserveSelectionOnInput.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-force-prompt-new-line",
tab: "terminal",
labelKey: "settings.terminal.behavior.forcePromptNewLine",
descriptionKey: "settings.terminal.behavior.forcePromptNewLine.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-tab-double-click",
tab: "terminal",
@@ -472,6 +547,55 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.behavior.osc52Clipboard.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-scroll-on-input",
tab: "terminal",
labelKey: "settings.terminal.behavior.scrollOnInput",
descriptionKey: "settings.terminal.behavior.scrollOnInput.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-scroll-on-output",
tab: "terminal",
labelKey: "settings.terminal.behavior.scrollOnOutput",
descriptionKey: "settings.terminal.behavior.scrollOnOutput.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-scroll-on-key-press",
tab: "terminal",
labelKey: "settings.terminal.behavior.scrollOnKeyPress",
descriptionKey: "settings.terminal.behavior.scrollOnKeyPress.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-scroll-on-paste",
tab: "terminal",
labelKey: "settings.terminal.behavior.scrollOnPaste",
descriptionKey: "settings.terminal.behavior.scrollOnPaste.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-smooth-scrolling",
tab: "terminal",
labelKey: "settings.terminal.behavior.smoothScrolling",
descriptionKey: "settings.terminal.behavior.smoothScrolling.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-link-browser",
tab: "terminal",
labelKey: "settings.terminal.behavior.linkBrowser",
descriptionKey: "settings.terminal.behavior.linkBrowser.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-link-modifier",
tab: "terminal",
labelKey: "settings.terminal.behavior.linkModifier",
descriptionKey: "settings.terminal.behavior.linkModifier.desc",
sectionKey: "settings.terminal.section.behavior",
},
{
id: "terminal-scrollback-rows",
tab: "terminal",
@@ -493,6 +617,27 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.sidePanel.autoOpen.desc",
sectionKey: "settings.terminal.section.sidePanel",
},
{
id: "terminal-side-panel-auto-open-pane",
tab: "terminal",
labelKey: "settings.terminal.sidePanel.autoOpenPane",
descriptionKey: "settings.terminal.sidePanel.autoOpenPane.desc",
sectionKey: "settings.terminal.section.sidePanel",
},
{
id: "terminal-local-shell-side-panel-auto-open",
tab: "terminal",
labelKey: "settings.terminal.localShellSidePanel.autoOpen",
descriptionKey: "settings.terminal.localShellSidePanel.autoOpen.desc",
sectionKey: "settings.terminal.section.localShellSidePanel",
},
{
id: "terminal-local-shell-side-panel-auto-open-pane",
tab: "terminal",
labelKey: "settings.terminal.localShellSidePanel.autoOpenPane",
descriptionKey: "settings.terminal.localShellSidePanel.autoOpenPane.desc",
sectionKey: "settings.terminal.section.localShellSidePanel",
},
{
id: "terminal-keyword-highlight",
tab: "terminal",
@@ -507,6 +652,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.localShell.shell.desc",
sectionKey: "settings.terminal.section.localShell",
},
{
id: "terminal-local-shell-start-dir",
tab: "terminal",
labelKey: "settings.terminal.localShell.startDir",
descriptionKey: "settings.terminal.localShell.startDir.desc",
sectionKey: "settings.terminal.section.localShell",
},
{
id: "terminal-default-proxy",
tab: "terminal",
@@ -552,6 +704,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.connection.keepaliveInterval.desc",
sectionKey: "settings.terminal.section.connection",
},
{
id: "terminal-keepalive-count-max",
tab: "terminal",
labelKey: "settings.terminal.connection.keepaliveCountMax",
descriptionKey: "settings.terminal.connection.keepaliveCountMax.desc",
sectionKey: "settings.terminal.section.connection",
},
{
id: "terminal-x11-display",
tab: "terminal",
@@ -560,6 +719,21 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.terminal.section.connection",
keywords: ["x11", "display"],
},
{
id: "terminal-host-info-bar-show",
tab: "terminal",
labelKey: "settings.terminal.hostInfoBar.show",
descriptionKey: "settings.terminal.hostInfoBar.show.desc",
sectionKey: "settings.terminal.section.serverStats",
},
{
id: "terminal-host-info-bar-title-mode",
fallbackAnchorId: "terminal-host-info-bar-show",
tab: "terminal",
labelKey: "settings.terminal.hostInfoBar.titleMode",
descriptionKey: "settings.terminal.hostInfoBar.titleMode.desc",
sectionKey: "settings.terminal.section.serverStats",
},
{
id: "terminal-server-stats-show",
tab: "terminal",
@@ -567,6 +741,42 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.serverStats.show.desc",
sectionKey: "settings.terminal.section.serverStats",
},
{
id: "terminal-server-stats-refresh-interval",
fallbackAnchorId: "terminal-server-stats-show",
tab: "terminal",
labelKey: "settings.terminal.serverStats.refreshInterval",
descriptionKey: "settings.terminal.serverStats.refreshInterval.desc",
sectionKey: "settings.terminal.section.serverStats",
},
{
id: "terminal-system-manager-process-refresh-interval",
tab: "terminal",
labelKey: "settings.terminal.systemManager.processRefreshInterval",
descriptionKey: "settings.terminal.systemManager.processRefreshInterval.desc",
sectionKey: "settings.terminal.section.systemManager",
},
{
id: "terminal-system-manager-tmux-refresh-interval",
tab: "terminal",
labelKey: "settings.terminal.systemManager.tmuxRefreshInterval",
descriptionKey: "settings.terminal.systemManager.tmuxRefreshInterval.desc",
sectionKey: "settings.terminal.section.systemManager",
},
{
id: "terminal-system-manager-docker-list-refresh-interval",
tab: "terminal",
labelKey: "settings.terminal.systemManager.dockerListRefreshInterval",
descriptionKey: "settings.terminal.systemManager.dockerListRefreshInterval.desc",
sectionKey: "settings.terminal.section.systemManager",
},
{
id: "terminal-system-manager-docker-stats-refresh-interval",
tab: "terminal",
labelKey: "settings.terminal.systemManager.dockerStatsRefreshInterval",
descriptionKey: "settings.terminal.systemManager.dockerStatsRefreshInterval.desc",
sectionKey: "settings.terminal.section.systemManager",
},
{
id: "terminal-renderer",
tab: "terminal",
@@ -575,6 +785,53 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.terminal.section.rendering",
keywords: ["webgl", "canvas"],
},
{
id: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernateHiddenTabs",
descriptionKey: "settings.terminal.rendering.hibernateHiddenTabs.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-hibernate-hidden-tabs-delay",
fallbackAnchorId: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernateHiddenTabsDelay",
descriptionKey: "settings.terminal.rendering.hibernateHiddenTabsDelay.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-hibernate-skip-alt-screen",
fallbackAnchorId: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernateSkipAltScreen",
descriptionKey: "settings.terminal.rendering.hibernateSkipAltScreen.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-hibernate-keep-renderer-count",
fallbackAnchorId: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernateKeepRendererCount",
descriptionKey: "settings.terminal.rendering.hibernateKeepRendererCount.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-hibernate-replay-chunk-bytes",
fallbackAnchorId: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernateReplayChunkBytes",
descriptionKey: "settings.terminal.rendering.hibernateReplayChunkBytes.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-hibernate-prefer-wasm-serialize",
fallbackAnchorId: "terminal-hibernate-hidden-tabs",
tab: "terminal",
labelKey: "settings.terminal.rendering.hibernatePreferWasmSerialize",
descriptionKey: "settings.terminal.rendering.hibernatePreferWasmSerialize.desc",
sectionKey: "settings.terminal.section.rendering",
},
{
id: "terminal-inline-images-enabled",
tab: "terminal",
@@ -583,6 +840,54 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.terminal.section.inlineImages",
keywords: ["sixel", "kitty", "iip"],
},
{
id: "terminal-inline-images-kitty",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.kitty",
descriptionKey: "settings.terminal.inlineImages.kitty.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-inline-images-sixel",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.sixel",
descriptionKey: "settings.terminal.inlineImages.sixel.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-inline-images-iip",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.iip",
descriptionKey: "settings.terminal.inlineImages.iip.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-inline-images-storage-limit",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.storageLimit",
descriptionKey: "settings.terminal.inlineImages.storageLimit.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-inline-images-max-megapixels",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.maxMegapixels",
descriptionKey: "settings.terminal.inlineImages.maxMegapixels.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-inline-images-sequence-limit",
fallbackAnchorId: "terminal-inline-images-enabled",
tab: "terminal",
labelKey: "settings.terminal.inlineImages.sequenceLimit",
descriptionKey: "settings.terminal.inlineImages.sequenceLimit.desc",
sectionKey: "settings.terminal.section.inlineImages",
},
{
id: "terminal-workspace-focus-style",
tab: "terminal",
@@ -598,6 +903,27 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.terminal.section.autocomplete",
keywords: ["completion", "补全"],
},
{
id: "terminal-autocomplete-ghost-text",
tab: "terminal",
labelKey: "settings.terminal.autocomplete.ghostText",
descriptionKey: "settings.terminal.autocomplete.ghostText.desc",
sectionKey: "settings.terminal.section.autocomplete",
},
{
id: "terminal-autocomplete-popup-menu",
tab: "terminal",
labelKey: "settings.terminal.autocomplete.popupMenu",
descriptionKey: "settings.terminal.autocomplete.popupMenu.desc",
sectionKey: "settings.terminal.section.autocomplete",
},
{
id: "terminal-autocomplete-history-scope",
tab: "terminal",
labelKey: "settings.terminal.autocomplete.historyScope",
descriptionKey: "settings.terminal.autocomplete.historyScope.desc",
sectionKey: "settings.terminal.section.autocomplete",
},
{
id: "terminal-password-prompt-assist",
tab: "terminal",
@@ -779,6 +1105,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "ai.defaultAgent",
descriptionKey: "ai.defaultAgent.description",
},
{
id: "ai-privacy",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.privacy.title",
descriptionKey: "ai.privacy.description",
},
{
id: "ai-chat-shortcuts-selection",
tab: "ai",
@@ -819,6 +1152,29 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "ai.externalMcp.title",
keywords: ["notification", "approval", "dock"],
},
{
id: "ai-chat-history-message-cap",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.chatHistory.messageCap",
descriptionKey: "ai.chatHistory.messageCap.description",
sectionKey: "ai.chatHistory.title",
},
{
id: "ai-external-mcp-behavior",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.toolSettings.behavior",
sectionKey: "ai.externalMcp.title",
},
{
id: "ai-external-mcp-client-configuration",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.externalMcp.clientConfiguration",
descriptionKey: "ai.externalMcp.clientConfiguration.description",
sectionKey: "ai.externalMcp.title",
},
{
id: "ai-user-skills",
tab: "ai",
@@ -850,6 +1206,31 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "ai.webSearch.provider.description",
sectionKey: "ai.webSearch.title",
},
{
id: "ai-web-search-api-key",
fallbackAnchorId: "ai-web-search-provider",
tab: "ai",
aiSubTab: "search",
labelKey: "ai.webSearch.apiKey",
descriptionKey: "ai.webSearch.apiKey.description",
sectionKey: "ai.webSearch.title",
},
{
id: "ai-web-search-api-host",
tab: "ai",
aiSubTab: "search",
labelKey: "ai.webSearch.apiHost",
descriptionKey: "ai.webSearch.apiHost.description",
sectionKey: "ai.webSearch.title",
},
{
id: "ai-web-search-max-results",
tab: "ai",
aiSubTab: "search",
labelKey: "ai.webSearch.maxResults",
descriptionKey: "ai.webSearch.maxResults.description",
sectionKey: "ai.webSearch.title",
},
{
id: "ai-safety-permission-mode",
tab: "ai",
@@ -876,6 +1257,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "ai.safety.title",
keywords: ["response", "request", "idle", "timeout", "响应", "请求", "超时"],
},
{
id: "ai-safety-max-iterations",
tab: "ai",
aiSubTab: "safety",
labelKey: "ai.safety.maxIterations",
descriptionKey: "ai.safety.maxIterations.description",
sectionKey: "ai.safety.title",
},
{
id: "ai-safety-blocklist",
tab: "ai",
@@ -915,6 +1304,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "cloudSync.strategy.title",
descriptionKey: "cloudSync.strategy.desc",
},
{
id: "sync-ai-sessions",
tab: "sync",
syncSubTab: "status",
labelKey: "cloudSync.aiSessions.title",
descriptionKey: "cloudSync.aiSessions.desc",
},
{
id: "sync-local-backups",
tab: "sync",
@@ -962,6 +1358,22 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.system.networkProxy.title",
keywords: ["proxy", "http", "代理"],
},
{
id: "system-network-proxy-url",
fallbackAnchorId: "system-network-proxy-mode",
tab: "system",
labelKey: "settings.system.networkProxy.url",
descriptionKey: "settings.system.networkProxy.url.desc",
sectionKey: "settings.system.networkProxy.title",
},
{
id: "system-network-proxy-bypass",
fallbackAnchorId: "system-network-proxy-mode",
tab: "system",
labelKey: "settings.system.networkProxy.bypass",
descriptionKey: "settings.system.networkProxy.bypass.desc",
sectionKey: "settings.system.networkProxy.title",
},
{
id: "system-app-lock",
tab: "system",
@@ -969,6 +1381,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.appLock.description",
keywords: ["app lock", "applock", "lock", "应用锁", "锁屏", "idle", "timeout", "无操作"],
},
{
id: "system-app-lock-timeout",
fallbackAnchorId: "system-app-lock",
tab: "system",
labelKey: "settings.appLock.timeout",
descriptionKey: "settings.appLock.timeoutDesc",
sectionKey: "settings.appLock.title",
},
{
id: "system-credentials",
tab: "system",
@@ -1017,6 +1437,27 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.sessionLogs.enableAutoSaveDesc",
sectionKey: "settings.sessionLogs.title",
},
{
id: "system-session-logs-directory",
tab: "system",
labelKey: "settings.sessionLogs.directory",
descriptionKey: "settings.sessionLogs.directoryHint",
sectionKey: "settings.sessionLogs.title",
},
{
id: "system-session-logs-format",
tab: "system",
labelKey: "settings.sessionLogs.format",
descriptionKey: "settings.sessionLogs.formatDesc",
sectionKey: "settings.sessionLogs.title",
},
{
id: "system-session-logs-timestamps",
tab: "system",
labelKey: "settings.sessionLogs.timestamps",
descriptionKey: "settings.sessionLogs.timestampsDesc",
sectionKey: "settings.sessionLogs.title",
},
{
id: "system-ssh-deep-link",
tab: "system",
+37 -6
View File
@@ -27,6 +27,7 @@ const {
stripAnsi,
} = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
const { READINESS_UNKNOWN, SHELL_READINESS_UNKNOWN_MESSAGE } = require("./shellReadinessState.cjs");
const { getSessionSnapshot } = require("../terminalInterruptDiagnostics.cjs");
const { createJobStartupDiag } = require("./jobStartupDiagnostics.cjs");
@@ -70,6 +71,7 @@ function startPtyJob(ptyStream, command, options) {
sessionId = null,
diagnosticsSession = null,
getReadyPrompt,
classifyReadiness,
beforeInput,
promptWaitTimeoutMs = 30000,
typedInput = false,
@@ -85,6 +87,30 @@ function startPtyJob(ptyStream, command, options) {
let readinessFailureCode = null;
let waitingForInputGuard = typeof beforeInput === "function";
// #4195: an empty prompt at the bounded wait deadline is resolved into a
// PROVEN-busy hard block (unchanged) or an honest, actionable `unknown`.
// A missing/ambiguous classifier always fails closed to busy, so #4110's
// false-ready guards and legacy callers without classifyReadiness keep the
// exact SHELL_NOT_READY behavior. `unknown` NEVER sends the queued command.
function resolveUnreadyOutcome() {
if (typeof classifyReadiness !== "function") return "busy";
try {
return classifyReadiness() === "unknown" ? "unknown" : "busy";
} catch {
return "busy";
}
}
function finishUnready(busyMessage) {
if (resolveUnreadyOutcome() === "unknown") {
readinessFailureCode = READINESS_UNKNOWN;
finish("", -1, SHELL_READINESS_UNKNOWN_MESSAGE);
} else {
readinessFailureCode = "SHELL_NOT_READY";
finish("", -1, busyMessage);
}
}
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
let resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
loginShellHint,
@@ -203,10 +229,12 @@ function startPtyJob(ptyStream, command, options) {
wallTimeoutId = setTimeout(() => {
if (finished) return;
if (waitingForPrompt || waitingForInputGuard) {
readinessFailureCode = waitingForPrompt ? "SHELL_NOT_READY" : "EXECUTION_NOT_AUTHORIZED";
finish("", -1, waitingForPrompt
? "Shell prompt is not ready; no command was sent"
: "Execution authorization timed out; no command was sent");
if (waitingForPrompt) {
finishUnready("Shell prompt is not ready; no command was sent");
} else {
readinessFailureCode = "EXECUTION_NOT_AUTHORIZED";
finish("", -1, "Execution authorization timed out; no command was sent");
}
return;
}
if (pendingEnd) {
@@ -1017,8 +1045,10 @@ function startPtyJob(ptyStream, command, options) {
return;
}
if (Date.now() >= deadline) {
readinessFailureCode = "SHELL_NOT_READY";
finish("", -1, "Shell prompt is not ready (startup, busy program, pending input, or unrecognized prompt); no command was sent");
// Same bounded 30s budget, unchanged. Only the *verdict* differs:
// proven-busy stays a hard block; an unrecognized prompt on a live
// shell becomes an honest, non-terminal, actionable `unknown`.
finishUnready("Shell prompt is not ready (startup, busy program, pending input, or unrecognized prompt); no command was sent");
return;
}
readinessTimer = setTimeout(checkReady, Math.min(50, deadline - Date.now()));
@@ -1062,6 +1092,7 @@ function startPtyJob(ptyStream, command, options) {
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
* @param {() => string} [options.getReadyPrompt] - Read current prompt; wait without writing until non-empty
* @param {() => "busy"|"unknown"} [options.classifyReadiness] - At the bounded wait deadline, classify an empty prompt as PROVEN busy (hard SHELL_NOT_READY) or an honest actionable `unknown` (SHELL_READINESS_UNKNOWN). Absent/ambiguous ⇒ busy (fail closed). Never sends the command for `unknown`.
* @param {function} [options.beforeInput] - Revalidate authorization before the first PTY byte (may return a promise)
* @param {number} [options.promptWaitTimeoutMs=30000] - Bounded pre-input wait (maximum 30 seconds)
* @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution
@@ -0,0 +1,280 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { startPtyJob } = require("./ptyExec.cjs");
const {
trackSessionIdlePrompt,
getFreshIdlePrompt,
getVisibleIdlePrompt,
} = require("./shellUtils.cjs");
const {
READINESS_UNKNOWN,
SHELL_READINESS_UNKNOWN_MESSAGE,
classifyShellReadiness,
} = require("./shellReadinessState.cjs");
const {
formatTerminalExecuteMcpResponse,
} = require("../../capabilities/codegen/mcpToolRegistry.cjs");
const { MODEL_DESCRIPTION_HINTS } = require("../../capabilities/schemas/toolInputs.cjs");
function readyPrompt(session) {
return getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
}
function makeSession(extra = {}) {
return { _promptTrackTail: "", lastIdlePrompt: "", ...extra };
}
// Records every byte written to the PTY; echoes a completion marker so a
// genuinely-executed command resolves ok:true (same seam as
// ptyExec.readiness.test.cjs).
class TestPty extends EventEmitter {
writes = [];
write(data) {
this.writes.push(String(data));
const marker = String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1];
if (marker) queueMicrotask(() => this.emit("data", `${marker}_S\nOK\n${marker}_E:0\n`));
}
}
const UNKNOWN_PROMPT = "Last login: Mon\r\nstarship-custom-prompt─❯ ";
const SHORT_BUDGET = { promptWaitTimeoutMs: 30, timeoutMs: 2000 };
// ---------------------------------------------------------------------------
// classifier unit: PROVEN busy stays hard, whitelist miss on a live shell is
// an honest unknown. These are the #4110 false-ready guards we must not weaken.
// ---------------------------------------------------------------------------
test("classifyShellReadiness: proven-busy OSC 133 / pending-input / task / transport stay busy", () => {
assert.equal(classifyShellReadiness(makeSession({ osc133Ready: false })), "busy"); // C/D
assert.equal(classifyShellReadiness(makeSession({ _osc133PromptRedrawPending: true })), "busy");
assert.equal(classifyShellReadiness(makeSession({ _osc133PromptContinued: true })), "busy");
assert.equal(classifyShellReadiness(makeSession({ osc133Ready: true, _osc133PromptLine: 3 })), "busy"); // B cursor deviation
assert.equal(classifyShellReadiness(makeSession({ waitingForInput: true })), "busy");
assert.equal(classifyShellReadiness(makeSession({ busy: true })), "busy");
assert.equal(classifyShellReadiness(makeSession({ status: "disconnected" })), "busy");
assert.equal(classifyShellReadiness(makeSession(), { taskInFlight: true }), "busy");
assert.equal(classifyShellReadiness(makeSession(), { transportAlive: false }), "busy");
assert.equal(classifyShellReadiness(null), "busy"); // fail closed
});
test("classifyShellReadiness: unrecognized prompt on a live shell is unknown (not ready, not busy)", () => {
const session = makeSession();
trackSessionIdlePrompt(session, UNKNOWN_PROMPT);
assert.equal(readyPrompt(session), ""); // genuine whitelist miss
assert.equal(classifyShellReadiness(session), "unknown");
// A-only marker with no C/D and no B confirmation is still unknown (no busy evidence).
assert.equal(classifyShellReadiness(makeSession({ osc133Ready: true, _osc133PromptLine: null })), "unknown");
});
test("classifyShellReadiness: credential prompts remain hard busy (#4195)", () => {
for (const line of ["Password:", "[sudo] password for alice: ", "Enter passphrase for key '/root/.ssh/id_rsa': ", "PIN: ", "Sorry, try again."]) {
const session = makeSession({ _promptTrackTail: line });
assert.equal(readyPrompt(session), "", line);
assert.equal(classifyShellReadiness(session), "busy", line);
}
});
// ---------------------------------------------------------------------------
// gating trichotomy end-to-end — EVERY case asserts PTY bytes written.
// ---------------------------------------------------------------------------
test("ready (whitelist match) → command sent, unchanged", async () => {
const pty = new TestPty();
const prompt = "user@host:~$ ";
const result = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => prompt,
classifyReadiness: () => "unknown", // never consulted when a prompt is recognized
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.ok, true);
assert.equal(pty.writes.length, 1);
});
test("ready (OSC 133 A with empty input) → command sent, unchanged (#4110)", async () => {
const pty = new TestPty();
const session = makeSession();
trackSessionIdlePrompt(session, "\x1b]133;A\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ");
assert.equal(readyPrompt(session), "~/src ❯ ");
const result = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => readyPrompt(session),
classifyReadiness: () => classifyShellReadiness(session),
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.ok, true);
assert.equal(pty.writes.length, 1);
});
test("PROVEN busy (OSC 133 C/D) → hard SHELL_NOT_READY, ZERO bytes written (#4110 guard)", async () => {
const pty = new TestPty();
const session = makeSession();
trackSessionIdlePrompt(session, "\x1b]133;A\x07PS C:\\Users\\alice> ");
assert.equal(readyPrompt(session), "PS C:\\Users\\alice> ");
trackSessionIdlePrompt(session, "\x1b]133;C\x07build running...\r\nPS C:\\Users\\alice> ");
assert.equal(readyPrompt(session), "");
assert.equal(classifyShellReadiness(session), "busy");
const result = await startPtyJob(pty, "echo OK", {
shellKind: "powershell",
getReadyPrompt: () => readyPrompt(session),
classifyReadiness: () => classifyShellReadiness(session),
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, "SHELL_NOT_READY");
assert.deepEqual(pty.writes, []);
});
test("PROVEN busy (pending-input / our task / disconnected) → hard block, ZERO bytes written", async () => {
for (const extra of [
{ _osc133PromptContinued: true },
{ _osc133PromptRedrawPending: true },
{ waitingForInput: true },
{ busy: true },
{ status: "disconnected" },
]) {
const pty = new TestPty();
const session = makeSession(extra);
trackSessionIdlePrompt(session, UNKNOWN_PROMPT);
assert.equal(readyPrompt(session), "");
const result = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => readyPrompt(session),
classifyReadiness: () => classifyShellReadiness(session),
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, "SHELL_NOT_READY", JSON.stringify(extra));
assert.deepEqual(pty.writes, [], JSON.stringify(extra));
}
});
test("unknown (unrecognized prompt, live shell, no busy evidence): command NOT sent, actionable NON-fatal result", async () => {
const pty = new TestPty();
const session = makeSession();
trackSessionIdlePrompt(session, UNKNOWN_PROMPT);
assert.equal(readyPrompt(session), "");
assert.equal(classifyShellReadiness(session), "unknown");
const result = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => readyPrompt(session),
classifyReadiness: () => classifyShellReadiness(session),
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, READINESS_UNKNOWN);
assert.equal(result.ok, false); // NOT a lie of success
assert.equal(result.error, SHELL_READINESS_UNKNOWN_MESSAGE);
assert.match(result.error, /connection is alive/i);
assert.match(result.error, /could not be recognized/i);
assert.match(result.error, /NOT sent/i);
assert.match(result.error, /read-only/i);
assert.match(result.error, /get_environment|terminal_read_context/);
assert.deepEqual(pty.writes, []); // no blind typing
});
test("unknown keeps the same bounded wait (no early send, no early verdict)", async () => {
const pty = new TestPty();
let prompt = "";
const job = startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => prompt,
classifyReadiness: () => "unknown",
promptWaitTimeoutMs: 80,
timeoutMs: 2000,
});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(pty.writes, []); // still waiting at the bounded budget
const result = await job.resultPromise;
assert.equal(result.code, READINESS_UNKNOWN);
assert.deepEqual(pty.writes, []);
});
test("unknown is recoverable: a recognized prompt (user pressed Enter) lets the next exec succeed", async () => {
const pty = new TestPty();
let prompt = "";
const first = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => prompt,
classifyReadiness: () => "unknown",
...SHORT_BUDGET,
}).resultPromise;
assert.equal(first.code, READINESS_UNKNOWN);
assert.deepEqual(pty.writes, []); // first exec never bricked the session
prompt = "user@host:~$ ";
const second = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => prompt,
classifyReadiness: () => "unknown",
...SHORT_BUDGET,
}).resultPromise;
assert.equal(second.ok, true);
assert.equal(pty.writes.length, 1);
});
test("Password: remains hard not-ready, ZERO bytes written (#4195 correct behavior)", async () => {
const pty = new TestPty();
const session = makeSession();
trackSessionIdlePrompt(session, "Password:");
assert.equal(readyPrompt(session), "");
assert.equal(classifyShellReadiness(session), "busy");
const result = await startPtyJob(pty, "echo OK", {
shellKind: "posix",
getReadyPrompt: () => readyPrompt(session),
classifyReadiness: () => classifyShellReadiness(session),
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, "SHELL_NOT_READY");
assert.deepEqual(pty.writes, []);
});
test("legacy callers without classifyReadiness keep the exact hard SHELL_NOT_READY block", async () => {
const pty = new TestPty();
const result = await startPtyJob(pty, "echo OK", {
getReadyPrompt: () => "",
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, "SHELL_NOT_READY");
assert.deepEqual(pty.writes, []);
});
test("a throwing / ambiguous classifier fails closed to busy (never a false ready)", async () => {
const pty = new TestPty();
const result = await startPtyJob(pty, "echo OK", {
getReadyPrompt: () => "",
classifyReadiness: () => { throw new Error("inspection blew up"); },
...SHORT_BUDGET,
}).resultPromise;
assert.equal(result.code, "SHELL_NOT_READY");
assert.deepEqual(pty.writes, []);
});
// ---------------------------------------------------------------------------
// agent-visible surfaces: MCP tool result + tool description.
// ---------------------------------------------------------------------------
test("MCP terminal_execute surfaces unknown as a NON-fatal, actionable result (not isError)", () => {
const unknownResult = {
ok: false,
error: SHELL_READINESS_UNKNOWN_MESSAGE,
code: READINESS_UNKNOWN,
exitCode: -1,
stdout: "",
stderr: "",
};
const formatted = formatTerminalExecuteMcpResponse(unknownResult);
assert.notEqual(formatted.isError, true); // THE fix: agent does not see a terminal error
assert.match(formatted.content[0].text, /read-only/i);
assert.match(formatted.content[0].text, /NOT sent/i);
assert.match(formatted.content[0].text, /get_environment|terminal_read_context/);
// Control: a proven-busy verdict is still a hard error.
const busy = formatTerminalExecuteMcpResponse({ ...unknownResult, code: "SHELL_NOT_READY", error: "Shell prompt is not ready; no command was sent" });
assert.equal(busy.isError, true);
});
test("terminal_execute tool description documents the unknown outcome and the read-only/confirm path", () => {
const description = MODEL_DESCRIPTION_HINTS["terminal.execute"] || "";
assert.match(description, /SHELL_READINESS_UNKNOWN/);
assert.match(description, /NOT sent/);
assert.match(description, /read-only/);
assert.match(description, /get_environment|terminal_read_context/);
assert.match(description, /ask the user to confirm/);
});
+106
View File
@@ -0,0 +1,106 @@
"use strict";
/**
* First-class shell readiness classification for the pre-input gate in
* startPtyJob. This composes #4110's OSC 133 marker authority and #4195's
* asymmetric-harm thesis: a whitelist miss on a live shell must be an
* actionable `unknown`, never a terminal SHELL_NOT_READY — but anything
* PROVEN busy (or a credential prompt waiting for the user's secret) must
* stay a hard block. It mirrors #3935's honesty rule: `unknown` is NOT a lie
* of success and is never claimed as ready.
*
* This module only READS session state. It never writes to a PTY, never
* injects OSC markers, and never widens tool scope. It lives outside
* shellUtils.cjs (the prompt whitelist) on purpose so the gating consumer
* evolves independently of positive-signal additions such as #4197.
*/
const { stripAnsi } = require("./shellUtils.cjs");
/** Distinct, non-terminal code emitted when the gate cannot prove busy. */
const READINESS_UNKNOWN = "SHELL_READINESS_UNKNOWN";
/**
* Honest, actionable result text. Surfaced verbatim as the exec result
* `error` and as the MCP/Catty tool-result text. It must never claim ready.
* (a) connection alive + no busy signal, (b) prompt unrecognized,
* (c) command NOT sent / no blind typing, (d) the viable read-only path.
*/
const SHELL_READINESS_UNKNOWN_MESSAGE = [
"Shell readiness unknown (SHELL_READINESS_UNKNOWN): the terminal connection is",
"alive and no busy, pending-input, or credential-prompt signal was observed, but",
"the prompt could not be recognized (custom theme, colored/starship/root prompt,",
"or continuation). The command was NOT sent — never blind-type into a possible",
"password prompt, editor, or half-typed command. This is a recoverable,",
"non-terminal state, not a failed command. Read-only next steps: call",
"get_environment or terminal_read_context to inspect the visible buffer, re-check",
"readiness after the user presses Enter or a recognized prompt appears, reuse",
"another idle session, or ask the user to confirm the prompt before retrying.",
"Retrying once the prompt is recognized is safe.",
].join(" ");
// Known credential/passphrase prompts. #4195: `Password:` (and friends) are
// PROVEN "waiting for secret input" states and MUST remain hard not-ready.
// This is a conservative block-list (hardening only): a miss falls through to
// `unknown`, which still never types.
const BLOCKING_INPUT_PATTERNS = [
/^(?:\[sudo\]\s*|enter\s+|please\s+enter\s+)?(?:password|passphrase|pin)(?:\s+for\s+[^:]+)?\s*:?\s*$/i,
/^sorry,\s*try\s+again\.?\s*$/i,
];
function lastVisibleLine(session) {
const tail = typeof session?._promptTrackTail === "string" ? session._promptTrackTail : "";
if (!tail) return "";
const lines = stripAnsi(tail).split(/[\r\n]/);
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i].trim();
if (line) return line;
}
return "";
}
/**
* Classify a session whose getReadyPrompt() just returned empty (i.e. no
* whitelist match and no OSC 133 A-with-empty-input ready signal).
*
* Returns:
* "busy" — PROVEN busy → keep the hard SHELL_NOT_READY block unchanged.
* "unknown" — connection alive, no proven-busy evidence, prompt not
* recognized → actionable, non-terminal outcome.
*
* Anything ambiguous (missing session, thrown helper, unknown context) fails
* CLOSED to "busy", so #4110's false-ready guards are never weakened.
*/
function classifyShellReadiness(session, context = {}) {
if (!session) return "busy";
const { taskInFlight = false, transportAlive = true } = context || {};
// Our own tracked task in flight / a dead transport are proven busy and are
// also hard-blocked at the exec call sites (reserveSessionExecution,
// writability check) and by the PTY close/error handlers.
if (taskInFlight || !transportAlive) return "busy";
// #4110: an OSC 133 C/D marker establishes a running command/output
// lifecycle. shellUtils.cjs:321 sets osc133Ready=false; getFreshIdlePrompt
// (:377) and getVisibleIdlePrompt (:406) both return "" for that state.
if (session.osc133Ready === false) return "busy";
// #4110 pending-input guards (shellUtils.cjs:265-275): redraw latch,
// continuation/PS2, and B-marker cursor deviation. An empty prompt while
// osc133Ready===true with a B-marked line means matchesOsc133EmptyPrompt
// rejected it → the user typed into the prompt line.
if (session._osc133PromptRedrawPending) return "busy";
if (session._osc133PromptContinued) return "busy";
if (session.osc133Ready === true && session._osc133PromptLine != null) return "busy";
// Explicit busy / waiting-for-input session state (#3935 recovery model:
// sessionRecoveryService.cjs:55-56, 101-102).
if (session.waitingForInput === true || session.busy === true) return "busy";
if (session.status === "disconnected" || session.status === "stopped") return "busy";
// Credential prompt waiting for the user's secret (#4195).
const line = lastVisibleLine(session);
if (line && BLOCKING_INPUT_PATTERNS.some((pattern) => pattern.test(line))) return "busy";
// Connection alive, no proven-busy evidence, prompt not recognized.
return "unknown";
}
module.exports = {
READINESS_UNKNOWN,
SHELL_READINESS_UNKNOWN_MESSAGE,
classifyShellReadiness,
};
@@ -4,6 +4,7 @@
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const { formatSyntheticEcho, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { classifyShellReadiness } = require("../ai/shellReadinessState.cjs");
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
@@ -227,6 +228,7 @@ function registerCattyExecHandlers(ctx) {
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
classifyReadiness: () => classifyShellReadiness(session),
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -384,7 +384,9 @@ test("MCP background readiness timeout survives job polling without PTY writes",
const polled = api.handleJobPoll({ jobId: started.jobId, chatSessionId: "readiness-chat" });
assert.equal(polled.status, "failed");
assert.equal(polled.completed, true);
assert.equal(polled.code, "SHELL_NOT_READY");
// #4195: a live shell whose prompt was never recognized is an actionable
// unknown, not a terminal SHELL_NOT_READY. The command is still never sent.
assert.equal(polled.code, "SHELL_READINESS_UNKNOWN");
assert.equal(ctx.activeSessionExecutions.size, 0);
assert.deepEqual(pty.writes, []);
ctx.getFreshIdlePrompt = () => "user@host:~$ ";
@@ -3,6 +3,7 @@
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { classifyShellReadiness } = require("../ai/shellReadinessState.cjs");
const { emitJobDiag } = require("../ai/jobStartupDiagnostics.cjs");
const {
ensureSessionShellKind,
@@ -161,6 +162,7 @@ function createExecHandlerApi(ctx) {
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
classifyReadiness: () => classifyShellReadiness(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -343,6 +345,7 @@ function createExecHandlerApi(ctx) {
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
classifyReadiness: () => classifyShellReadiness(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
+38
View File
@@ -0,0 +1,38 @@
const { randomUUID } = require("node:crypto");
// Keep the renderer alive until its ConnectionLog replay writes have committed.
function finalizeSessionLogs(webContents, { ipcMain, timeoutMs = 5000 } = {}) {
if (!webContents || webContents.isDestroyed?.() || webContents.isCrashed?.()
|| typeof webContents.send !== "function"
|| typeof ipcMain?.on !== "function"
|| typeof ipcMain?.removeListener !== "function") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const requestId = randomUUID();
const finish = (success) => {
clearTimeout(timer);
try {
ipcMain.removeListener("app:session-logs-finalized", onResult);
} catch {
// IPC teardown must not prevent the window from closing.
}
if (!success) console.warn("[SessionLogs] Renderer did not finalize logs before close");
resolve(success);
};
const onResult = (event, payload) => {
if (event.sender === webContents && payload?.requestId === requestId) {
finish(payload.success === true);
}
};
const timer = setTimeout(() => finish(false), timeoutMs);
try {
ipcMain.on("app:session-logs-finalized", onResult);
webContents.send("app:finalize-session-logs", { requestId });
} catch {
finish(false);
}
});
}
module.exports = { finalizeSessionLogs };
@@ -0,0 +1,193 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const { finalizeSessionLogs } = require("./sessionLogShutdown.cjs");
const { createPreloadApi } = require("../preload/api.cjs");
const { handleBeforeQuit } = require("../main/appLockLifecycle.cjs");
const { readFileSync } = require("node:fs");
const vm = require("node:vm");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function renderer(ipcMain = new EventEmitter()) {
const ipcRenderer = new EventEmitter();
const webContents = { send: (channel, payload) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
return { api, ipcMain, ipcRenderer, webContents };
}
test("finalization waits for every terminal replay commit, including a hidden renderer", async () => {
const h = renderer();
const commits = [];
const off = h.api.onFinalizeSessionLogs(() => { throw new Error("unmounted terminal"); });
off();
for (let i = 0; i < 2; i++) {
h.api.onFinalizeSessionLogs(() => new Promise((resolve) => commits.push(resolve)));
}
let done = false;
const pending = finalizeSessionLogs(h.webContents, h).then((result) => { done = true; return result; });
await tick();
assert.equal(done, false);
commits[0](); await tick();
assert.equal(done, false);
commits[1]();
assert.equal(await pending, true);
assert.equal(h.ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("empty renderers acknowledge immediately; failed captures acknowledge failure", async () => {
const h = renderer();
assert.equal(await finalizeSessionLogs(h.webContents, h), true);
h.api.onFinalizeSessionLogs(() => { throw new Error("capture failed"); });
assert.equal(await finalizeSessionLogs(h.webContents, h), false);
});
test("overlapping window close and app quit share the pending replay commit", async () => {
const h = renderer();
let finish;
let captures = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finish = resolve; });
});
const first = finalizeSessionLogs(h.webContents, h);
let secondDone = false;
const second = finalizeSessionLogs(h.webContents, h).then((result) => { secondDone = true; return result; });
await tick();
assert.equal(captures, 1);
assert.equal(secondDone, false);
finish();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("only the queried renderer and current request can acknowledge finalization", async () => {
const ipcMain = new EventEmitter();
let request;
const webContents = { send: (_channel, payload) => { request = payload; } };
let done = false;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((result) => { done = true; return result; });
ipcMain.emit("app:session-logs-finalized", { sender: {} }, { ...request, success: true });
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { requestId: "old", success: true });
await tick();
assert.equal(done, false);
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { ...request, success: true });
assert.equal(await pending, true);
});
test("unresponsive and destroyed renderers do not strand quit or leak listeners", async () => {
const ipcMain = new EventEmitter();
assert.equal(await finalizeSessionLogs({ send() {} }, { ipcMain, timeoutMs: 1 }), false);
assert.equal(await finalizeSessionLogs({ send() { throw new Error("destroyed"); } }, { ipcMain }), false);
assert.equal(await finalizeSessionLogs({ isDestroyed: () => true }, { ipcMain }), false);
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("unavailable finalize IPC returns immediately without scheduling timers or sending", async (t) => {
const timer = t.mock.method(globalThis, "setTimeout");
const ipcMain = new EventEmitter();
const send = t.mock.fn();
const on = t.mock.method(ipcMain, "on");
for (const unavailableIpc of [undefined, null, {}, { on() {} }, { removeListener() {} }, { on: true, removeListener() {} }, { on() {}, removeListener: true }]) {
assert.equal(await finalizeSessionLogs({ send }, { ipcMain: unavailableIpc }), false);
}
for (const webContents of [undefined, null, {}, { send: true }]) {
assert.equal(await finalizeSessionLogs(webContents, { ipcMain }), false);
}
assert.equal(timer.mock.callCount(), 0);
assert.equal(on.mock.callCount(), 0);
assert.equal(send.mock.callCount(), 0);
});
for (const failure of ["registration", "send", "cleanup"]) {
test(`finalize IPC ${failure} errors do not reject or leave a pending timeout`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const warn = t.mock.method(console, "warn", () => {});
const ipcMain = new EventEmitter();
const webContents = { send() { throw new Error("send failed"); } };
if (failure === "registration") {
ipcMain.on = (channel, listener) => {
EventEmitter.prototype.on.call(ipcMain, channel, listener);
throw new Error("registration failed");
};
} else if (failure === "cleanup") {
ipcMain.removeListener = (channel, listener) => {
EventEmitter.prototype.removeListener.call(ipcMain, channel, listener);
throw new Error("cleanup failed");
};
}
let result;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((success) => { result = success; });
await tick();
assert.equal(result, false, "failed setup must finish before the timeout");
await pending;
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
assert.equal(warn.mock.callCount(), 1);
t.mock.timers.tick(5000);
assert.equal(warn.mock.callCount(), 1, "the cleared timeout must not run after finalization");
});
}
test("tray quit finalizes only after the dirty guard accepts and waits before quitting", async () => {
const h = renderer();
let finishCapture;
let captures = 0;
let quits = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finishCapture = resolve; });
});
let shutdown;
const options = {
event: { preventDefault() {} }, mainWindows: [{ webContents: h.webContents }],
queryDirtyEditors: async () => true,
commitQuit: () => { shutdown = finalizeSessionLogs(h.webContents, h).then(() => { quits++; }); },
dialog: { showMessageBoxSync() {} },
};
await handleBeforeQuit(options);
assert.equal(captures, 0);
await handleBeforeQuit({ ...options, queryDirtyEditors: async () => false });
await tick();
assert.equal(captures, 1);
assert.equal(quits, 0);
finishCapture(); await shutdown;
assert.equal(quits, 1);
});
test("production quit commit awaits renderer logs and file streams before plugins and process exit", async () => {
const source = readFileSync(require.resolve("../main.cjs"), "utf8");
const start = source.indexOf(" const commitQuit = () => {");
const end = source.indexOf('\n app.on("before-quit"', start);
assert.ok(start >= 0 && end > start);
const calls = [];
let finishCapture;
let finishStreams;
const hiddenWindow = { webContents: {} };
const context = {
console,
getWindowManager: () => ({ setIsQuitting: () => calls.push("quitting"), getAppContentWindows: () => [hiddenWindow] }),
electronModule: { ipcMain: {} },
finalizeSessionLogs: (wc) => {
assert.equal(wc, hiddenWindow.webContents);
calls.push("capture");
return new Promise((resolve) => { finishCapture = resolve; });
},
sessionLogStreamManager: { cleanupAll: () => {
calls.push("streams");
return new Promise((resolve) => { finishStreams = resolve; });
} },
appLockController: { setLocked: () => calls.push("lock") },
runPluginShutdown: async () => { calls.push("plugins"); return { timedOut: false }; },
app: { quit: () => calls.push("quit") },
quitGuardChannelBusy: false, quitConfirmed: false,
};
vm.runInNewContext(`${source.slice(start, end)}; commitQuit();`, context);
assert.deepEqual(calls, ["lock", "quitting", "capture"]);
assert.equal(context.quitGuardChannelBusy, true);
finishCapture(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams"]);
assert.equal(context.quitConfirmed, false);
finishStreams(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams", "plugins", "quit"]);
assert.equal(context.quitConfirmed, true);
});
@@ -16,10 +16,29 @@ const {
startStream,
startStreamToFile,
stopStream,
cleanupAll,
} = require("./sessionLogStreamManager.cjs");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
test("quit cleanup finishes all pending file captures before resolving", async () => {
const directory = path.join(TEMP_ROOT, `quit-${Date.now()}`);
try {
for (const sessionId of ["quit-one", "quit-two"]) {
startStreamToFile(sessionId, { filePath: path.join(directory, `${sessionId}.log`), format: "raw" });
appendData(sessionId, `output before quit: ${sessionId}\r\n`);
}
await cleanupAll();
for (const sessionId of ["quit-one", "quit-two"]) {
assert.equal(hasStream(sessionId), false);
assert.equal(fs.readFileSync(path.join(directory, `${sessionId}.log`), "utf8"), `output before quit: ${sessionId}\r\n`);
}
} finally {
await cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -4,6 +4,7 @@ const {
} = require("./windowsWindowChrome.cjs");
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const perfTrace = require("../../perfTrace.cjs");
const { finalizeSessionLogs } = require("../sessionLogShutdown.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
@@ -534,11 +535,12 @@ function createMainWindowApi(ctx) {
: false;
})();
Promise.resolve(dirtyEditorQuery)
.then((hasDirty) => {
.then(async (hasDirty) => {
if (hasDirty) {
thisWindowCloseRequested = false;
return;
}
await finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain });
dirtyEditorCloseConfirmed = true;
try {
win.close();
@@ -10,10 +10,18 @@ for (const peer of [true, false]) {
let ownsTabs = true;
let ownerChecks = 0;
let dirtyChecks = 0;
const ipcMain = new EventEmitter();
let finishLogCapture;
let logCaptures = 0;
let closeToTray = false;
class Window extends EventEmitter {
constructor() {
super(); this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} });
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send: (channel, payload) => {
if (channel !== "app:finalize-session-logs") return;
logCaptures++;
finishLogCapture = () => ipcMain.emit("app:session-logs-finalized", { sender: this.webContents }, { requestId: payload.requestId, success: true });
} });
}
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } }
isDestroyed() { return this.destroyed; }
@@ -30,7 +38,11 @@ for (const peer of [true, false]) {
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getGlobalShortcutBridge: () => ({ handleWindowClose: (event) => {
if (!closeToTray) return false;
event.preventDefault();
return true;
} }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
@@ -43,10 +55,18 @@ for (const peer of [true, false]) {
closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); },
queryDirtyEditors: async () => { dirtyChecks++; return false; },
});
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, {
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true,
electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer,
});
if (!peer) {
closeToTray = true;
win.close(); await tick();
assert.equal(win.destroyed, false);
assert.equal(logCaptures, 0, "hiding to tray must keep the session log live");
assert.equal(ownerChecks, 0);
closeToTray = false;
}
win.close(); win.close();
assert.equal(ownerChecks, 1, "repeat close must share the ownership check");
assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt");
@@ -54,10 +74,14 @@ for (const peer of [true, false]) {
finish({ success: true, cancelled: true }); await tick();
assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive");
assert.equal(dirtyChecks, 0);
assert.equal(logCaptures, 0, "Cancel must leave the live log ongoing");
win.close();
ownsTabs = false;
finish({ success: true, cancelled: false }); await tick();
assert.equal(dirtyChecks, 1);
assert.equal(logCaptures, 1);
assert.equal(win.destroyed, false, "renderer must survive until replay persistence acknowledges completion");
finishLogCapture(); await tick();
assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled");
});
}
@@ -81,6 +81,11 @@ function formatTerminalExecuteResult(result) {
// Align with Catty executor wording when a command ran but produced no text.
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
// Matches READINESS_UNKNOWN in electron/bridges/ai/shellReadinessState.cjs.
// Kept as a literal to avoid a capabilities -> bridges dependency; a test
// asserts the two stay in sync.
const SHELL_READINESS_UNKNOWN_CODE = "SHELL_READINESS_UNKNOWN";
/**
* Format terminal_execute for MCP clients.
* Match Catty semantics: when the command actually ran, always surface
@@ -97,6 +102,18 @@ function formatTerminalExecuteMcpResponse(result) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// #4195: an unrecognized prompt on a live shell is an actionable,
// non-terminal state, not a fatal tool error. The exec gate already refused
// to send the command; surface the honest + viable-path hint as a NORMAL
// tool result (no isError) so the agent inspects / re-checks / confirms
// instead of treating the session as permanently bricked (cf. #3935, where
// shellReady became advisory). Never claim the command ran.
if (result.code === SHELL_READINESS_UNKNOWN_CODE) {
return {
content: [{ type: "text", text: result.error || "Shell readiness unknown; no command was sent." }],
};
}
// Operational failure with no command evidence (session not found, busy, blocked…).
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
+1 -1
View File
@@ -418,7 +418,7 @@ const MODEL_DESCRIPTION_HINTS = Object.freeze({
"session.environment":
"Call this first for any task involving a live Netcatty terminal, remote server, SSH session, SFTP path, or terminal tab. Select the target by label or hostname, then pass its sessionId to terminal and SFTP tools. Call it again after the user opens, closes, or renames a session.",
"terminal.execute":
"Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd).",
"Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd). If the shell prompt cannot be recognized while the connection is alive and no busy signal is present, the command is NOT sent and the tool returns SHELL_READINESS_UNKNOWN (a recoverable, non-terminal result, not a failed command): do not blind-type into a possible password prompt — inspect the terminal read-only with get_environment or terminal_read_context, re-check readiness after the user presses Enter, reuse another idle session, or ask the user to confirm the prompt before retrying.",
"terminal.start":
"Use this instead of the local shell for long-running commands on a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Prefer for builds, scans, log-following, or anything likely to exceed about 2 minutes. Shell state such as cd or export does not persist between calls — combine cd with the command.",
"terminal.poll":
+12 -6
View File
@@ -237,6 +237,7 @@ const {
} = require("./main/appLockLifecycle.cjs");
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
const { finalizeSessionLogs } = require("./bridges/sessionLogShutdown.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -1665,7 +1666,17 @@ if (!gotLock) {
}
getWindowManager().setIsQuitting(true);
quitGuardChannelBusy = true;
void runPluginShutdown()
void (async () => {
try {
await Promise.all(getWindowManager().getAppContentWindows().map((win) => (
finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain })
)));
await sessionLogStreamManager.cleanupAll();
} catch (error) {
console.warn("[SessionLogs] Shutdown failed; continuing app quit:", error);
}
return runPluginShutdown();
})()
.then(({ timedOut }) => {
if (timedOut) console.warn("[Plugins] Shutdown deadline elapsed; continuing app quit");
})
@@ -1727,11 +1738,6 @@ if (!gotLock) {
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
sessionLogStreamManager.cleanupAll();
} catch (err) {
console.warn("Error during session log stream cleanup:", err);
}
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
+17
View File
@@ -7,6 +7,19 @@ const fs = require("node:fs");
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
function createPreloadApi(ctx) {
const sessionLogFinalizers = new Set();
let pendingSessionLogFinalization = null;
ctx.ipcRenderer?.on?.("app:finalize-session-logs", async (_event, { requestId }) => {
const pending = pendingSessionLogFinalization ??= Promise.allSettled(
[...sessionLogFinalizers].map((finalize) => Promise.resolve().then(finalize)),
);
const results = await pending;
if (pendingSessionLogFinalization === pending) pendingSessionLogFinalization = null;
ctx.ipcRenderer.send("app:session-logs-finalized", {
requestId,
success: results.every((result) => result.status === "fulfilled"),
});
});
const terminalDataBacklog = ctx.terminalDataBacklog || null;
const displayDataListeners = ctx.displayDataListeners || new Map();
const closedTerminalDataSessions = ctx.closedTerminalDataSessions || null;
@@ -1544,6 +1557,10 @@ function createPreloadApi(ctx) {
reportDirtyEditorsResult: (hasDirty, reason, options) => ipcRenderer.send("app:dirty-editors-result", {
hasDirty, reason, requestId: options?.requestId, pending: options?.pending === true,
}),
onFinalizeSessionLogs: (finalize) => {
sessionLogFinalizers.add(finalize);
return () => sessionLogFinalizers.delete(finalize);
},
// Port Forwarding API
startPortForward: async (options) => {
+3
View File
@@ -9,6 +9,7 @@ const {
execViaRawPty,
} = require("../bridges/ai/ptyExec.cjs");
const { getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const { classifyShellReadiness } = require("../bridges/ai/shellReadinessState.cjs");
const {
ensureSessionShellKind,
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
@@ -339,6 +340,7 @@ function createWorkerAiExecHandler({
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
classifyReadiness: () => classifyShellReadiness(session),
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
@@ -568,6 +570,7 @@ function createWorkerAiJobStartHandler({
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
classifyReadiness: () => classifyShellReadiness(session),
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
+3 -1
View File
@@ -678,7 +678,9 @@ test("worker background readiness timeout survives job polling without PTY write
});
assert.equal(polled.status, "failed");
assert.equal(polled.completed, true);
assert.equal(polled.code, "SHELL_NOT_READY");
// #4195: a live shell whose prompt was never recognized is an actionable
// unknown, not a terminal SHELL_NOT_READY. The command is still never sent.
assert.equal(polled.code, "SHELL_READINESS_UNKNOWN");
assert.deepEqual(pty.writes, []);
trackSessionIdlePrompt(session, "user@host:~$ ");
t.mock.timers.tick(1000);
+16 -3
View File
@@ -98,29 +98,42 @@ export function formatToolErrorContent(error: unknown, fallback = 'Tool executio
return JSON.stringify({ error: fallback });
}
// Non-terminal readiness verdict surfaced by the terminal.execute gate
// (#4195/#4198). Kept in sync with READINESS_UNKNOWN in
// electron/bridges/ai/shellReadinessState.cjs and SHELL_READINESS_UNKNOWN_CODE
// in electron/capabilities/codegen/mcpToolRegistry.cjs. The active Catty SDK
// path (capabilityTools.ts -> agentEventAdapter.ts -> AgentRuntime) preserves
// this `code` on the result so every error classifier can honour it.
export const SHELL_READINESS_UNKNOWN_CODE = 'SHELL_READINESS_UNKNOWN';
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
export function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// #4198: an unrecognized prompt on a live shell is an actionable,
// non-terminal state. Even if an `error` field is present, never flag it
// as a fatal tool error / session blocker.
if (obj.code === SHELL_READINESS_UNKNOWN_CODE) return false;
// Check for explicit error objects
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
// Check stringified JSON (common for tool result wrapping)
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === 'object') {
const parsedObj = parsed as Record<string, unknown>;
if (parsedObj.code === SHELL_READINESS_UNKNOWN_CODE) return false;
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
if ('ok' in parsedObj && parsedObj.ok === false) return true;
}
} catch { /* not JSON, not an error */ }
}
return false;
}
+8
View File
@@ -29,6 +29,7 @@ export interface NetcattyBridge {
stderr?: string;
exitCode?: number;
error?: string;
code?: string;
}>;
/**
* Cancel any in-flight Catty Agent command execution scoped to the
@@ -71,6 +72,13 @@ export interface ExecutorContext {
/** Convert a shared ToolExecResult into the executor's ToolResult format. */
function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
if (r.ok === false) {
// #4195: an unrecognized prompt on a live shell is an actionable,
// non-terminal state, not a fatal tool error. The exec gate already refused
// to send the command; surface the honest + viable-path hint as a normal
// tool result (no isError) so the agent inspects / re-checks / confirms.
if (r.code === 'SHELL_READINESS_UNKNOWN') {
return { toolCallId, content: r.error };
}
if (
typeof r.data === 'object'
&& r.data !== null
@@ -0,0 +1,76 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createToolExecutor, type NetcattyBridge, type ExecutorContext } from './executor';
// Mirrors SHELL_READINESS_UNKNOWN_MESSAGE from
// electron/bridges/ai/shellReadinessState.cjs (kept inline to avoid a
// renderer -> electron import in this harness test).
const UNKNOWN_HINT = [
'Shell readiness unknown (SHELL_READINESS_UNKNOWN): the terminal connection is',
'alive but the prompt could not be recognized. The command was NOT sent — never',
'blind-type. Read-only next steps: call get_environment or terminal_read_context,',
're-check after the user presses Enter, reuse another idle session, or ask the',
'user to confirm the prompt before retrying.',
].join(' ');
function makeBridge(result: Record<string, unknown>): NetcattyBridge {
return { aiExec: async () => result } as unknown as NetcattyBridge;
}
const context: ExecutorContext = {
sessions: [
{
sessionId: 's1',
hostId: 'h1',
hostname: 'host',
label: 'tab',
protocol: 'ssh',
connected: true,
},
],
};
function call(command = 'echo OK') {
return { id: 'call-1', name: 'terminal_execute', arguments: { sessionId: 's1', command } };
}
describe('Catty Agent terminal_execute surfaces SHELL_READINESS_UNKNOWN as non-fatal (#4195)', () => {
it('returns the actionable read-only hint WITHOUT isError (agent is not bricked)', async () => {
const bridge = makeBridge({
ok: false,
error: UNKNOWN_HINT,
code: 'SHELL_READINESS_UNKNOWN',
stdout: '',
stderr: '',
exitCode: -1,
});
const execute = createToolExecutor(bridge, context, [], 'auto');
const result = await execute(call());
assert.notEqual(result.isError, true);
assert.match(result.content, /read-only/i);
assert.match(result.content, /NOT sent/i);
assert.match(result.content, /get_environment|terminal_read_context/);
});
it('a proven-busy SHELL_NOT_READY verdict is still a hard error', async () => {
const bridge = makeBridge({
ok: false,
error: 'Shell prompt is not ready; no command was sent',
code: 'SHELL_NOT_READY',
stdout: '',
stderr: '',
exitCode: -1,
});
const execute = createToolExecutor(bridge, context, [], 'auto');
const result = await execute(call());
assert.equal(result.isError, true);
});
it('a normal successful command is unaffected', async () => {
const bridge = makeBridge({ ok: true, stdout: 'OK', stderr: '', exitCode: 0 });
const execute = createToolExecutor(bridge, context, [], 'auto');
const result = await execute(call());
assert.notEqual(result.isError, true);
assert.match(result.content, /OK/);
});
});
@@ -1,5 +1,5 @@
import type { AgentEvent, AgentEventListener } from './types';
import { resolveStreamChunkToolCallId } from '../aiChatStreamingSupport';
import { resolveStreamChunkToolCallId, SHELL_READINESS_UNKNOWN_CODE } from '../aiChatStreamingSupport';
let eventCounter = 0;
@@ -40,12 +40,18 @@ function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// #4198 (round 2): an unrecognized prompt on a live shell is an actionable,
// non-terminal state. Honour the preserved `code` so this result is never
// emitted as `isError: true` (which AgentRuntime stores as an open-session
// blocker), even if an `error` field is present. All other codes untouched.
if (obj.code === SHELL_READINESS_UNKNOWN_CODE) return false;
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output) as Record<string, unknown>;
if (parsed.code === SHELL_READINESS_UNKNOWN_CODE) return false;
if ('error' in parsed && typeof parsed.error === 'string') return true;
if ('ok' in parsed && parsed.ok === false) return true;
} catch {
@@ -16,6 +16,7 @@ import { redactSecretsForModel, redactSecretsInValueForModel, setReversibleModel
import { globalTerminalMonitorGuard } from './terminalMonitorGuard';
import { netcattyBridge } from '../../services/netcattyBridge';
import type { AISessionScope } from '../types';
import { SHELL_READINESS_UNKNOWN_CODE } from '../aiChatStreamingSupport';
let turnCounter = 0;
@@ -27,6 +28,11 @@ function nextTurnId(): string {
function isFailedToolResult(result: string): boolean {
try {
const parsed = JSON.parse(result) as Record<string, unknown>;
// #4198 (round 2): an unrecognized prompt on a live shell is an actionable,
// non-terminal state. Honour the preserved `code` so it is never stored as
// an open-session blocker, even if an `error` field is present. All other
// error codes keep the exact existing classification.
if (parsed?.code === SHELL_READINESS_UNKNOWN_CODE) return false;
return parsed?.ok === false || typeof parsed?.error === 'string';
} catch {
return false;
@@ -1,5 +1,6 @@
import { tool } from 'ai';
import { z } from 'zod';
import { SHELL_READINESS_UNKNOWN_CODE } from '../aiChatStreamingSupport';
import type { ExecutorContext, NetcattyBridge } from '../cattyAgent/executor';
import type { TerminalContextReadRange } from '../../../domain/terminalContextRead';
import type { AIPermissionMode } from '../types';
@@ -443,6 +444,33 @@ function createCatalogTool(spec: CattyToolSpec) {
try {
const result = await executeTerminalExecute(deps, { sessionId: sid, command });
if (result.ok === false) {
// #4195/#4198 (round 2): this is the ACTIVE Catty SDK path.
// cattyTurnDriver builds its tools with createCattyToolsFromCatalog,
// NOT the createToolExecutor path (which has no production callers).
// Falling through to the generic `fittedFailure` below re-wraps the
// result as `{ error: ... }` and DROPS its `code`, so every
// downstream error-shape classifier mis-handles it:
// - agentEventAdapter.ts:220 isToolResultError -> isError: true
// - agentRuntime.ts isFailedToolResult -> open session blocker
// - the shared isToolResultError (UI / external-sdk), staleContextPruner
// Instead surface the SAME honest + viable-path hint used by the
// MCP path (mcpToolRegistry.cjs) as a NORMAL tool result: preserve
// `code`, carry the hint as text, and use NO `error` / `ok:false`
// shape. All of the heuristics above then classify it as non-error
// without a wider refactor, while every OTHER error code (e.g.
// SHELL_NOT_READY) keeps the exact hard-error path below.
// No command ran, so skip completed-write dedup: a retry after the
// prompt becomes recognized must re-evaluate, not replay a stale
// "command not sent" result.
if (result.code === SHELL_READINESS_UNKNOWN_CODE) {
return {
content: [{
type: 'text',
text: result.error || 'Shell readiness unknown; no command was sent.',
}],
code: result.code,
};
}
if (!result.data) return unwrap(result);
const fittedFailure = {
error: fitLargeToolResultForModel({
@@ -135,7 +135,7 @@
"toolName": "terminal_execute",
"rpcMethod": "netcatty/exec",
"localExecution": false,
"description": "Execute a short command in a terminal session and wait for completion. Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd).",
"description": "Execute a short command in a terminal session and wait for completion. Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd). If the shell prompt cannot be recognized while the connection is alive and no busy signal is present, the command is NOT sent and the tool returns SHELL_READINESS_UNKNOWN (a recoverable, non-terminal result, not a failed command): do not blind-type into a possible password prompt — inspect the terminal read-only with get_environment or terminal_read_context, re-check readiness after the user presses Enter, reuse another idle session, or ask the user to confirm the prompt before retrying.",
"inputShape": {
"sessionId": {
"type": "string",
@@ -141,7 +141,7 @@
"toolName": "terminal_execute",
"rpcMethod": "netcatty/exec",
"localExecution": false,
"description": "Execute a short command in a terminal session and wait for completion. Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd).",
"description": "Execute a short command in a terminal session and wait for completion. Use this instead of the local shell when the command is intended for a Netcatty terminal or remote host. Call get_environment first to resolve the sessionId. Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd). If the shell prompt cannot be recognized while the connection is alive and no busy signal is present, the command is NOT sent and the tool returns SHELL_READINESS_UNKNOWN (a recoverable, non-terminal result, not a failed command): do not blind-type into a possible password prompt — inspect the terminal read-only with get_environment or terminal_read_context, re-check readiness after the user presses Enter, reuse another idle session, or ask the user to confirm the prompt before retrying.",
"inputShape": {
"sessionId": {
"type": "string",
@@ -0,0 +1,175 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
createCattyToolsFromCatalog,
withCattyToolContext,
} from './capabilityTools';
import { mapCattyStreamChunkToAgentEvents } from './agentEventAdapter';
import { AgentRuntime } from './agentRuntime';
import { SessionStateStore } from './sessionState';
import type { AgentEvent } from './types';
import type { TurnDriver, TurnDriverContext, TurnInput } from './turnDrivers/types';
// Reuse the #4198 fixture (infrastructure/ai/cattyAgent/
// shellReadiness.unknownState.test.ts) verbatim so the MCP path, the executor
// path and the ACTIVE Catty SDK path are asserted against the same honest,
// actionable hint.
const UNKNOWN_HINT = [
'Shell readiness unknown (SHELL_READINESS_UNKNOWN): the terminal connection is',
'alive but the prompt could not be recognized. The command was NOT sent — never',
'blind-type. Read-only next steps: call get_environment or terminal_read_context,',
're-check after the user presses Enter, reuse another idle session, or ask the',
'user to confirm the prompt before retrying.',
].join(' ');
const BUSY_HINT = 'Shell prompt is not ready; no command was sent';
const UNKNOWN_CODE = 'SHELL_READINESS_UNKNOWN';
const CHAT = 'chat-readiness';
function bundle(result: Record<string, unknown>) {
return createCattyToolsFromCatalog(
{ aiExec: async () => result } as never,
{
sessions: [{
sessionId: 's1',
hostId: 'h1',
hostname: 'host',
label: 'tab',
protocol: 'ssh',
connected: true,
}],
} as never,
[],
'auto',
undefined,
CHAT,
);
}
async function executeTerminal(result: Record<string, unknown>): Promise<Record<string, unknown>> {
const { tools, toolsContext } = bundle(result);
const execute = withCattyToolContext(
tools.terminal_execute as unknown as { execute: (...args: never[]) => unknown },
toolsContext.terminal_execute,
'call-1',
);
return await execute.execute({ sessionId: 's1', command: 'echo OK' } as never) as unknown as Record<string, unknown>;
}
function unknownResult(): Record<string, unknown> {
return {
ok: false, error: UNKNOWN_HINT, code: UNKNOWN_CODE,
stdout: '', stderr: '', exitCode: -1,
};
}
function busyResult(): Record<string, unknown> {
return {
ok: false, error: BUSY_HINT, code: 'SHELL_NOT_READY',
stdout: '', stderr: '', exitCode: -1,
};
}
function toCattyToolResultEvent(output: Record<string, unknown>): AgentEvent {
const events = mapCattyStreamChunkToAgentEvents(
{ type: 'tool-result', toolCallId: 'call-1', toolName: 'terminal_execute', output },
{ sessionId: CHAT, chatSessionId: CHAT },
);
assert.equal(events.length, 1);
return events[0];
}
// Drive a real AgentRuntime turn with the given tool-result event, then read
// back whether SessionStateStore registered an open-session blocker. This
// exercises agentRuntime.ts:188 (event.isError || isFailedToolResult(result)),
// the exact place the old { error: ... } wrapping bricked the session.
async function reinjectionFor(output: Record<string, unknown>): Promise<string | undefined> {
const toolResultEvent = toCattyToolResultEvent(output);
const sessionStateStore = new SessionStateStore();
const driver: TurnDriver = {
backend: 'catty',
async run(_input: TurnInput, ctx: TurnDriverContext): Promise<void> {
ctx.emit({
id: 'call',
type: 'tool_call',
toolCallId: 'call-1',
toolName: 'terminal_execute',
args: { sessionId: 's1', command: 'echo OK' },
} as unknown as AgentEvent);
ctx.emit({ ...toolResultEvent, id: 'result' } as unknown as AgentEvent);
},
};
const runtime = new AgentRuntime({ drivers: [driver], sessionStateStore });
await runtime.runTurn({
backend: 'catty',
chatSessionId: CHAT,
sendScopeKey: CHAT,
userText: 'check uptime',
signal: new AbortController().signal,
currentSession: undefined,
assistantMsgId: 'assistant-1',
context: {
activeProvider: undefined,
activeModelId: '',
scopeType: 'terminal',
globalPermissionMode: 'confirm',
terminalSessions: [],
autoTitleSession: () => {},
},
maxIterations: 5,
ui: {
addMessageToSession: () => {},
updateLastMessage: () => {},
updateMessageById: () => {},
reportStreamError: () => {},
setStreamingForScope: () => {},
},
} as unknown as TurnInput);
return sessionStateStore.toReinjectionText(CHAT);
}
describe('active Catty SDK path surfaces SHELL_READINESS_UNKNOWN as non-fatal (#4198 round 2)', () => {
it('capabilityTools preserves the code + honest hint and emits NO error shape', async () => {
const result = await executeTerminal(unknownResult());
assert.equal(result.code, UNKNOWN_CODE);
assert.equal('error' in result, false);
assert.notEqual(result.ok, false);
const text = JSON.stringify(result);
assert.match(text, /read-only|get_environment|terminal_read_context/);
assert.match(text, /NOT sent/i);
const content = result.content as Array<{ type: string; text: string }> | undefined;
assert.equal(content?.[0]?.text, UNKNOWN_HINT);
});
it('unknown => agentEventAdapter emits NO isError; SHELL_NOT_READY => still isError true', async () => {
const unknownEvent = toCattyToolResultEvent(await executeTerminal(unknownResult()));
assert.equal((unknownEvent as { isError?: boolean }).isError, false);
const busyEvent = toCattyToolResultEvent(await executeTerminal(busyResult()));
assert.equal((busyEvent as { isError?: boolean }).isError, true);
});
it('unknown registers NO open-session blocker; busy still registers one', async () => {
const unknownText = await reinjectionFor(await executeTerminal(unknownResult()));
assert.doesNotMatch(unknownText ?? '', /Open blockers/);
const busyText = await reinjectionFor(await executeTerminal(busyResult()));
assert.match(busyText ?? '', /Open blockers/);
});
it('defense-in-depth: a code-carrying error-shaped result stays non-error', async () => {
// Even if a future change re-adds an `error` field, the preserved `code`
// must keep it out of isError / the session blocker (classification guards).
const errorShaped = { error: UNKNOWN_HINT, code: UNKNOWN_CODE };
const event = toCattyToolResultEvent(errorShaped);
assert.equal((event as { isError?: boolean }).isError, false);
const text = await reinjectionFor(errorShaped);
assert.doesNotMatch(text ?? '', /Open blockers/);
});
it('a proven-busy error-shaped result without the unknown code is still a hard error', async () => {
const busyShaped = { error: BUSY_HINT, ok: false };
const event = toCattyToolResultEvent(busyShaped);
assert.equal((event as { isError?: boolean }).isError, true);
const text = await reinjectionFor(busyShaped);
assert.match(text ?? '', /Open blockers/);
});
});
+5 -2
View File
@@ -19,7 +19,7 @@ import { executeWebSearchProvider } from './webSearchProviders';
/** Discriminated union returned by every shared executor. */
export type ToolExecResult<T = unknown> =
| { ok: true; data: T }
| { ok: false; error: string; data?: T };
| { ok: false; error: string; data?: T; code?: string };
// ---------------------------------------------------------------------------
// Dependencies bundle
@@ -99,11 +99,14 @@ export async function executeTerminalExecute(
}
const result = await bridge.aiExec(sessionId, command, deps.chatSessionId);
// Real execution failures (timeout, disconnect, no stream) have an `error` field
// Real execution failures (timeout, disconnect, no stream) have an `error` field.
// Propagate `code` so consumers can distinguish an actionable, non-terminal
// SHELL_READINESS_UNKNOWN from a fatal error (#4195).
if (!result.ok && result.error) {
return {
ok: false,
error: result.error,
code: result.code,
data: {
stdout: result.stdout || '',
stderr: result.stderr || '',
+1 -1
View File
@@ -21,7 +21,7 @@ async function checkDevImport() {
server = await createServer({
cacheDir,
logLevel: "error",
server: { port: 0, strictPort: false, open: false },
server: { host: "127.0.0.1", port: 0, strictPort: false, open: false },
optimizeDeps: { entries: [], noDiscovery: true },
});
await server.listen();
+1
View File
@@ -299,6 +299,7 @@ declare global {
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
// Each response must echo the request ID, including pending-transfer acknowledgements.
onCheckDirtyEditors?(listener: (request: { requestId: string; deadline: number }) => void): () => void;
onFinalizeSessionLogs?(listener: () => Promise<void>): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers", options?: { requestId?: string; pending?: boolean }): void;