Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 | ||
|
|
f4aa6ddb46 | ||
|
|
f6cb73fdd6 | ||
|
|
3c100b0ae2 | ||
|
|
168e42b5fa | ||
|
|
2ce6bd5ed1 | ||
|
|
7bd5d6465a | ||
|
|
65387d4c61 | ||
|
|
6084e8e94f | ||
|
|
3ccc5c9fc6 | ||
|
|
d07859f604 | ||
|
|
88a322a03b | ||
|
|
0e02bbc2fb | ||
|
|
affd9217e2 | ||
|
|
7b4a349e3f | ||
|
|
7dc5ab5035 | ||
|
|
3e8965f9a9 | ||
|
|
23a27bf544 |
24
App.tsx
24
App.tsx
@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
@@ -1727,7 +1732,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
updateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
@@ -1756,15 +1763,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
||||
connectionLogs,
|
||||
{ sessionId, hostname: session?.hostname },
|
||||
);
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
|
||||
@@ -1935,7 +1935,7 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
|
||||
@@ -1967,7 +1967,7 @@ const ru: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Использует claude-agent-acp для потоковой передачи по протоколу ACP.',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
|
||||
'ai.claude.detecting': 'Обнаружение...',
|
||||
'ai.claude.detected': 'Обнаружен',
|
||||
'ai.claude.notFound': 'Не найден',
|
||||
|
||||
@@ -1944,7 +1944,7 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("backend exited events keep the tab and mark it disconnected", () => {
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
_evt: TerminalSessionExitEvent,
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
|
||||
// Explicit user closes bypass this policy and call the close-session path directly.
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
@@ -52,14 +52,19 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +99,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -402,17 +411,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -637,6 +637,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
// If the probe came back empty, drop any stale cached catalog for this
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
@@ -57,9 +57,9 @@ import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./term
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
markPromptLineBreakCommandPending,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -69,6 +69,7 @@ import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
|
||||
@@ -171,6 +172,7 @@ interface TerminalProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
@@ -261,6 +263,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
@@ -281,6 +284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -514,10 +518,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
markPromptLineBreakCommandPending(promptLineBreakStateRef);
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
@@ -548,7 +556,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
@@ -557,9 +565,40 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
|
||||
useEffect(() => {
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback(() => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTerminalCwd();
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
@@ -569,10 +608,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
@@ -584,7 +634,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
@@ -596,25 +646,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -864,6 +899,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
clearTerminalCwd();
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
@@ -887,7 +923,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onOsDetected,
|
||||
@@ -941,7 +980,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLineBufferRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onCwdChange: (cwd: string) => {
|
||||
terminalCwdTracker.setRendererCwd(cwd);
|
||||
knownCwdRef.current = cwd;
|
||||
onTerminalCwdChange?.(sessionId, cwd);
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
@@ -1211,6 +1252,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
@@ -1580,17 +1626,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleOpenSFTP = async () => {
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -1803,17 +1839,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const baseProps = {
|
||||
onAddKnownHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isBroadcastEnabled: () => true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, onToggleBroadcast: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ import { resolveScriptsSidePanelShortcutIntent } from '../application/state/reso
|
||||
import { terminalLayerAreEqual } from './terminalLayerMemo';
|
||||
import { getTerminalPaneSnapshot, parseTerminalPaneSnapshot } from './terminalPaneVisibility';
|
||||
import { getScopedTopTabsThemeId } from './terminalTopTabsTheme';
|
||||
import { resolvePreferredTerminalCwd } from './terminal/sftpCwd';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -504,6 +505,7 @@ interface TerminalPaneProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts: () => void;
|
||||
onOpenTheme: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
@@ -564,6 +566,7 @@ const terminalPanePropsAreEqual = (
|
||||
prev.sessionLog === next.sessionLog &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onOpenSftp === next.onOpenSftp &&
|
||||
prev.onTerminalCwdChange === next.onTerminalCwdChange &&
|
||||
prev.onOpenScripts === next.onOpenScripts &&
|
||||
prev.onOpenTheme === next.onOpenTheme &&
|
||||
prev.onCloseSession === next.onCloseSession &&
|
||||
@@ -612,6 +615,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
sessionLog,
|
||||
onHotkeyAction,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
onCloseSession,
|
||||
@@ -727,6 +731,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onOpenSftp={onOpenSftp}
|
||||
onTerminalCwdChange={onTerminalCwdChange}
|
||||
onOpenScripts={onOpenScripts}
|
||||
onOpenTheme={onOpenTheme}
|
||||
onCloseSession={onCloseSession}
|
||||
@@ -783,6 +788,7 @@ interface TerminalPanesHostProps {
|
||||
sessionLog?: { enabled: true; directory: string; format: string };
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onOpenSftp: TerminalPaneProps['onOpenSftp'];
|
||||
onTerminalCwdChange: TerminalPaneProps['onTerminalCwdChange'];
|
||||
onOpenScripts: () => void;
|
||||
onOpenTheme: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
@@ -894,6 +900,24 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
const isSftpActive = activeTabId === 'sftp';
|
||||
const isVisible = (!isVaultActive && !isSftpActive) || !!draggingSessionId;
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
|
||||
if (cwd && cwd.trim().length > 0) {
|
||||
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
|
||||
} else {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
for (const sessionId of terminalRendererCwdBySessionRef.current.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Stable callback references for Terminal components
|
||||
const handleCloseSession = useCallback((sessionId: string) => {
|
||||
@@ -954,10 +978,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(evt);
|
||||
if (intent.kind === "markDisconnected") {
|
||||
if (intent.kind === "closeSession") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
}, [onCloseSession, onUpdateSessionStatus]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
@@ -1772,13 +1798,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
return result.success && result.cwd ? result.cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
sessionId,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
@@ -2220,14 +2244,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
if (rawFocusedHost) {
|
||||
onUpdateHost({ ...rawFocusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
@@ -2236,14 +2262,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
if (rawFocusedHost) {
|
||||
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
@@ -3128,6 +3156,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLog={sessionLogConfig}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onOpenSftp={handleOpenSftp}
|
||||
onTerminalCwdChange={handleTerminalCwdChange}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onOpenTheme={handleOpenTheme}
|
||||
onCloseSession={handleCloseSession}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
|
||||
142
components/VaultView.sortPersistence.test.tsx
Normal file
142
components/VaultView.sortPersistence.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Host, SSHKey } from "../types.ts";
|
||||
import { VaultView } from "./VaultView.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const installStorageStub = (sortMode: string | null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (sortMode !== null) {
|
||||
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
|
||||
id,
|
||||
label,
|
||||
hostname: `${id}.example.com`,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
createdAt,
|
||||
group,
|
||||
});
|
||||
|
||||
const fallbackKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Fallback key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
installStorageStub(sortMode);
|
||||
const noop = () => {};
|
||||
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onToggleConnectionLogSaved: noop,
|
||||
onDeleteConnectionLog: noop,
|
||||
onClearUnsavedConnectionLogs: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("Hosts sort mode is restored from storage", () => {
|
||||
const markup = renderVault("za", [
|
||||
host("alpha", "Alpha Host", 1),
|
||||
host("zulu", "Zulu Host", 2),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts grouped sort mode is restored from storage", () => {
|
||||
const markup = renderVault("group", [
|
||||
host("beta", "Beta Host", 1, "Beta Group"),
|
||||
host("alpha", "Alpha Host", 2, "Alpha Group"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
|
||||
const markup = renderVault("unknown-sort", [
|
||||
host("zulu", "Zulu Host", 2),
|
||||
host("alpha", "Alpha Host", 1),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../application/state/useStoredString";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -121,6 +123,13 @@ type DropTarget =
|
||||
| { kind: "root" }
|
||||
| { kind: "group"; path: string };
|
||||
|
||||
const isSortMode = (value: string): value is SortMode =>
|
||||
value === "az" ||
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "group";
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
@@ -280,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
"az",
|
||||
isSortMode,
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
|
||||
@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'claude',
|
||||
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
@@ -894,7 +894,7 @@ export default function SettingsTerminalTab(props: {
|
||||
label={t("settings.terminal.behavior.forcePromptNewLine")}
|
||||
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? true} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
|
||||
@@ -47,11 +47,15 @@ export function buildManagedAgentState(
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const managedEnv = agentKey === "claude"
|
||||
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
|
||||
: existingManaged?.env;
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
...(managedEnv ? { env: managedEnv } : {}),
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
|
||||
@@ -450,3 +450,32 @@ test("applyKeystroke: ignores non-typing data (escape sequences, control codes)"
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// We believe only "network in" is typed; suggestion is the full command.
|
||||
addon.show("network interface show", "network in");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// The real line shows MORE than we tracked: a bastion host echoed the
|
||||
// next char ("t") that our client-side buffer never recorded.
|
||||
const line = "ecOS# network int";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = line.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
@@ -361,6 +387,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
@@ -112,9 +113,16 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013), hide rather than draw the ghost over
|
||||
// already-typed text. Done here (post-echo render) rather than in
|
||||
// show()/adjustToInput so it never fights the keystroke-time path.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -291,6 +299,23 @@ export class GhostTextAddon implements IDisposable {
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the real terminal line has more input than we tracked, so
|
||||
* rendering the ghost would paint over already-typed characters. See
|
||||
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
|
||||
* we can't judge (non-ASCII, echo still catching up), so the ghost only
|
||||
* gets suppressed when corruption is actually imminent.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term || !this.currentInput) return false;
|
||||
const buf = this.term.buffer.active;
|
||||
if (typeof buf?.getLine !== "function") return false;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
if (!line || typeof line.translateToString !== "function") return false;
|
||||
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
|
||||
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Fail-safe consistency check for inline (ghost-text) suggestions.
|
||||
*
|
||||
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
|
||||
* cursor, where `trackedInput` is what the client thinks the user has typed.
|
||||
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
|
||||
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
|
||||
* of sync with what is actually on the terminal line, and the ghost ends up
|
||||
* painted over characters the user already typed (`int` + ghost `terface` →
|
||||
* `intterface`).
|
||||
*
|
||||
* This detects the one direction that produces visible corruption: the real
|
||||
* line being AHEAD of the tracked input (it contains the tracked input
|
||||
* followed by more, untracked characters). SSH echo latency is the opposite
|
||||
* case — the line is a prefix-behind of the tracked input — and is
|
||||
* intentionally NOT flagged, so the ghost stays responsive on slow links.
|
||||
*
|
||||
* Returns true when the caller should hide the ghost.
|
||||
*/
|
||||
export function lineHasUntrackedTrailingInput(
|
||||
trackedInput: string,
|
||||
lineBeforeCursor: string,
|
||||
): boolean {
|
||||
// Single chars match too loosely to judge reliably; let them through.
|
||||
if (trackedInput.length < 2) return false;
|
||||
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
|
||||
// existing wide-char (CJK / emoji) handling is left untouched.
|
||||
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
|
||||
|
||||
// Use the last occurrence so a prompt or command that repeats the same token
|
||||
// earlier on the line doesn't shadow the freshly-typed input.
|
||||
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
|
||||
if (idx < 0) {
|
||||
// Tracked input isn't on screen yet — the echo is still catching up
|
||||
// (latency). Keep the ghost; reality being behind never corrupts.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-whitespace characters between the tracked input and the cursor mean the
|
||||
// device echoed input we never tracked → the ghost would overlap real text.
|
||||
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
|
||||
}
|
||||
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
@@ -20,7 +20,29 @@ const NON_PROMPT_PATTERNS = [
|
||||
/^:\s*$/, // vim command mode
|
||||
/^\s*~\s*$/, // vim tilde lines
|
||||
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
|
||||
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
|
||||
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
|
||||
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
|
||||
/^SQL>\s/i, // sqlplus SQL> prompts
|
||||
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
|
||||
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
|
||||
/^pry\([^)]*\)>\s/i,
|
||||
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
|
||||
/^lftp\s+\S+>\s/i,
|
||||
/^\s{3}\.{3}>\s/,
|
||||
/^cqlsh(?::[\w.-]+)?>\s/i,
|
||||
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
|
||||
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
|
||||
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
|
||||
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
|
||||
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
|
||||
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
|
||||
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
|
||||
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
|
||||
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
|
||||
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
|
||||
];
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
@@ -38,30 +60,48 @@ const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
(trimmed.endsWith(">") || trimmed.endsWith("›"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
|
||||
}
|
||||
|
||||
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (/^root[#%$]$/.test(trimmed)) return true;
|
||||
if (trimmed.length < 3) return false;
|
||||
|
||||
const marker = trimmed[trimmed.length - 1];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
|
||||
|
||||
const prev = trimmed[trimmed.length - 2] ?? "";
|
||||
return /[~:/\\\])]/.test(prev);
|
||||
}
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* actually matches the tail of the raw detected userInput. Returns
|
||||
* null otherwise — the single signal downstream uses to decide
|
||||
* whether to record it as the executed command.
|
||||
* can be validated against the live terminal line. Returns null
|
||||
* otherwise - the single signal downstream uses to decide whether
|
||||
* to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
}
|
||||
|
||||
function replacePromptUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
userInput: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText,
|
||||
userInput,
|
||||
cursorOffset: userInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorLinePrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
@@ -72,6 +112,499 @@ function getCursorLinePrefix(term: XTerm): string | null {
|
||||
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
|
||||
}
|
||||
|
||||
function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line?.isWrapped) return null;
|
||||
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) return null;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (!promptLine) return null;
|
||||
|
||||
let prefix = promptLine.translateToString(false);
|
||||
for (let row = promptRow + 1; row < cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (!rowLine) return null;
|
||||
prefix += rowLine.translateToString(false);
|
||||
}
|
||||
|
||||
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
|
||||
}
|
||||
|
||||
function inferPromptTextBeforeTypedInput(
|
||||
cursorPrefix: string,
|
||||
typedBuffer: string,
|
||||
allowPartialEcho: boolean,
|
||||
): string | null {
|
||||
if (cursorPrefix.endsWith(typedBuffer)) {
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
|
||||
return promptText.length > 0 ? promptText : null;
|
||||
}
|
||||
|
||||
if (!allowPartialEcho) return null;
|
||||
|
||||
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
|
||||
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
|
||||
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (promptText.length > 0) return promptText;
|
||||
}
|
||||
|
||||
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
|
||||
for (
|
||||
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
|
||||
echoLength >= noSpacePromptMinEchoLength;
|
||||
echoLength--
|
||||
) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
const hasReliablePartialEcho =
|
||||
typedBuffer.trim().length <= 2 ||
|
||||
echoedInput.endsWith(" ") ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4);
|
||||
if (!hasReliablePartialEcho) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
|
||||
const candidate = promptText.slice(0, promptBoundary).trimEnd();
|
||||
const finalIndex = candidate.length - 1;
|
||||
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
|
||||
|
||||
for (let i = 0; i < finalIndex; i++) {
|
||||
const ch = candidate[i];
|
||||
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
|
||||
|
||||
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
|
||||
if (nextChar === null || nextChar === " ") continue;
|
||||
|
||||
const earlierPrompt = candidate.slice(0, i + 1);
|
||||
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
|
||||
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
|
||||
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
|
||||
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
|
||||
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
|
||||
if (finalChar !== "$") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
|
||||
if (promptText.length === 0) return false;
|
||||
if (rawIsAtPrompt) return true;
|
||||
|
||||
const promptBoundary = findPromptBoundary(promptText);
|
||||
const promptEndsAtBoundary =
|
||||
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
return (
|
||||
promptEndsAtBoundary &&
|
||||
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
|
||||
isSpecificShellPromptCandidate(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isThemedPromptText(promptText: string): boolean {
|
||||
for (const ch of promptText) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return /[❯❮→➜➤⟩»›]/.test(promptText);
|
||||
}
|
||||
|
||||
function isPromptPathDecoration(trimmed: string): boolean {
|
||||
return (
|
||||
trimmed === "~" ||
|
||||
trimmed.startsWith("~/") ||
|
||||
trimmed.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
|
||||
trimmed.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptBareDirectoryText(trimmed: string): boolean {
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
|
||||
return /^[\w.-]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function isPromptStatusToken(token: string): boolean {
|
||||
return (
|
||||
/^git:\([^)]*\)$/.test(token) ||
|
||||
/^[+$#%>!?*]$/.test(token) ||
|
||||
token === "✗" ||
|
||||
token === "✔"
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptStatusText(trimmed: string): boolean {
|
||||
const [first = "", ...rest] = trimmed.split(/\s+/);
|
||||
if (rest.length === 0) return false;
|
||||
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
|
||||
return rest.every(isPromptStatusToken);
|
||||
}
|
||||
|
||||
function isPromptStatusDecoration(extra: string): boolean {
|
||||
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
|
||||
|
||||
return isPromptStatusText(extra.trim());
|
||||
}
|
||||
|
||||
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (!isThemedPromptText(promptText)) return false;
|
||||
if (startsWithCommonShellCommand(extra)) return false;
|
||||
if (/^\s*\S+\s+$/.test(extra)) {
|
||||
return isPromptPathDecoration(trimmed) || (
|
||||
isPromptBareDirectoryText(trimmed) &&
|
||||
!startsWithCommonShellCommand(trimmed)
|
||||
);
|
||||
}
|
||||
if (isPromptStatusDecoration(extra)) return true;
|
||||
for (const ch of extra) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFinalPromptBoundary(promptText: string): number {
|
||||
const trimmedEnd = promptText.trimEnd().length;
|
||||
if (trimmedEnd === 0) return -1;
|
||||
|
||||
const markerIndex = trimmedEnd - 1;
|
||||
const marker = promptText[markerIndex];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
|
||||
|
||||
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") return -1;
|
||||
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
|
||||
}
|
||||
|
||||
function endsAtFinalPromptBoundary(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
}
|
||||
|
||||
const COMMON_SHELL_COMMANDS = new Set([
|
||||
"alias",
|
||||
"awk",
|
||||
"az",
|
||||
"brew",
|
||||
"bun",
|
||||
"bundle",
|
||||
"cargo",
|
||||
"cat",
|
||||
"cd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"code",
|
||||
"composer",
|
||||
"cp",
|
||||
"curl",
|
||||
"docker",
|
||||
"echo",
|
||||
"emacs",
|
||||
"env",
|
||||
"export",
|
||||
"find",
|
||||
"gcloud",
|
||||
"gh",
|
||||
"git",
|
||||
"go",
|
||||
"gradle",
|
||||
"grep",
|
||||
"helm",
|
||||
"java",
|
||||
"javac",
|
||||
"kubectl",
|
||||
"less",
|
||||
"ls",
|
||||
"make",
|
||||
"mkdir",
|
||||
"mvn",
|
||||
"mv",
|
||||
"nano",
|
||||
"node",
|
||||
"npm",
|
||||
"npx",
|
||||
"nvim",
|
||||
"php",
|
||||
"pip",
|
||||
"pip3",
|
||||
"pnpm",
|
||||
"printf",
|
||||
"python",
|
||||
"python3",
|
||||
"rails",
|
||||
"rm",
|
||||
"rsync",
|
||||
"ruby",
|
||||
"rustc",
|
||||
"scp",
|
||||
"screen",
|
||||
"sed",
|
||||
"ssh",
|
||||
"sudo",
|
||||
"tail",
|
||||
"tar",
|
||||
"terraform",
|
||||
"tmux",
|
||||
"touch",
|
||||
"uv",
|
||||
"vi",
|
||||
"vim",
|
||||
"yarn",
|
||||
]);
|
||||
|
||||
function getLeadingShellCommandWord(text: string): string | null {
|
||||
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function startsWithCommonShellCommand(text: string): boolean {
|
||||
const command = getLeadingShellCommandWord(text);
|
||||
return command !== null && COMMON_SHELL_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
function isCompleteSpecificPrompt(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return (
|
||||
promptBoundary >= 0 &&
|
||||
promptText.slice(promptBoundary).trim().length === 0 &&
|
||||
isSpecificShellPromptCandidate(promptText) &&
|
||||
!isEmbeddedPromptMarker(promptText, promptBoundary)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
|
||||
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
|
||||
const trimmedStart = text.trimStart();
|
||||
if (startsWithCommonShellCommand(trimmedStart)) return true;
|
||||
|
||||
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
|
||||
return startsWithCommonShellCommand(afterDecoration);
|
||||
}
|
||||
|
||||
function isSingleBareDirectoryExtra(extra: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
|
||||
}
|
||||
|
||||
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
|
||||
return /^\s+\S+\s+$/.test(extra);
|
||||
}
|
||||
|
||||
type PromptDecorationReconcileOptions = {
|
||||
allowSingleWordCommandDirectory?: boolean;
|
||||
};
|
||||
|
||||
function canTreatCommonCommandNameAsThemedDirectory(
|
||||
extra: string,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
const trimmedInput = typedInput.trim();
|
||||
return (
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
(
|
||||
/\s/.test(trimmedInput) ||
|
||||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
|
||||
(
|
||||
options.allowSingleWordCommandDirectory === true &&
|
||||
hasExplicitThemedDirectorySpacing(extra)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canReconcilePromptDecoration(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
if (
|
||||
!prompt.isAtPrompt ||
|
||||
!typedInput ||
|
||||
prompt.userInput.length <= typedInput.length ||
|
||||
!prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
|
||||
if (
|
||||
isThemedPromptText(prompt.promptText) &&
|
||||
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatePromptText = prompt.promptText + extra;
|
||||
const promptEndsAtBoundary =
|
||||
endsAtFinalPromptBoundary(candidatePromptText) &&
|
||||
isSpecificShellPromptCandidate(candidatePromptText);
|
||||
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
|
||||
}
|
||||
|
||||
function alignTypedInputFromCursorPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
cursorPrefix: string | null,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!cursorPrefix) return null;
|
||||
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
|
||||
|
||||
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
|
||||
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function canUseReliablePromptPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): boolean {
|
||||
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typedBuffer.length <= raw.userInput.length) return false;
|
||||
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
|
||||
allowShortEcho: allowsShortPromptEcho(raw.promptText),
|
||||
});
|
||||
}
|
||||
|
||||
function isLikelyBareMongoPromptName(promptName: string): boolean {
|
||||
return /^(?:test|admin|local|config)$/i.test(promptName);
|
||||
}
|
||||
|
||||
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const promptName = trimmed.slice(0, -1).trim();
|
||||
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
|
||||
}
|
||||
|
||||
function endsWithStandardShellPrompt(promptText: string): boolean {
|
||||
const finalChar = promptText.trimEnd().at(-1);
|
||||
return finalChar === "$" || finalChar === "#" || finalChar === "%";
|
||||
}
|
||||
|
||||
function allowsShortPromptEcho(promptText: string): boolean {
|
||||
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
|
||||
}
|
||||
|
||||
function isReliableTypedPrefix(
|
||||
echoedInput: string,
|
||||
typedBuffer: string,
|
||||
options: { allowShortEcho?: boolean } = {},
|
||||
): boolean {
|
||||
if (!typedBuffer.startsWith(echoedInput)) return false;
|
||||
if (
|
||||
options.allowShortEcho &&
|
||||
typedBuffer.trim().length <= 2 &&
|
||||
echoedInput.trim().length >= 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
|
||||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4)
|
||||
);
|
||||
}
|
||||
|
||||
function withTypedUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
...prompt,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function alignThemedDecorationWithPartialEcho(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
|
||||
|
||||
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
|
||||
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!raw.userInput.endsWith(echoedInput)) continue;
|
||||
|
||||
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
|
||||
if (extra.length === 0) continue;
|
||||
const hasReliableThemedDirectoryPrefix =
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
hasExplicitThemedDirectorySpacing(extra) &&
|
||||
typedBuffer.trim().length <= 3 &&
|
||||
echoedInput.trim().length >= 1;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...raw,
|
||||
userInput: extra + typedBuffer,
|
||||
cursorOffset: extra.length + typedBuffer.length,
|
||||
};
|
||||
if (
|
||||
!hasReliableThemedDirectoryPrefix &&
|
||||
!isReliableTypedPrefix(echoedInput, typedBuffer)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) continue;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText: raw.promptText + extra,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -88,15 +621,26 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const lineText = line.translateToString(false);
|
||||
|
||||
// Check for non-prompt patterns (pagers, editors, etc.)
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return NO_PROMPT;
|
||||
if (isNonPromptLine(lineText)) return NO_PROMPT;
|
||||
if (line.isWrapped) {
|
||||
const wrappedPrefix = getWrappedCursorPrefix(term);
|
||||
if (wrappedPrefix && isNonPromptLine(wrappedPrefix)) return NO_PROMPT;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
// Try to find the prompt boundary on the current line
|
||||
const promptEnd = findPromptBoundary(lineText);
|
||||
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
|
||||
// Try to find the prompt boundary on the current line. xterm buffer rows are
|
||||
// padded with blank cells; when the cursor is at the visible row end, scan
|
||||
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
|
||||
// trailing space. If there is command text to the right of the cursor, keep
|
||||
// the full line so "$" / ">" inside mid-line edits are validated against
|
||||
// their real following character.
|
||||
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Use cursor position to determine actual input length — don't trim trailing
|
||||
@@ -125,6 +669,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (promptLine) {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
@@ -139,6 +684,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
|
||||
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
|
||||
const cursorOffset = userInput.length;
|
||||
if (isNonPromptLine(promptText + userInput)) return NO_PROMPT;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
@@ -165,6 +711,56 @@ function isPuaChar(ch: string): boolean {
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
}
|
||||
|
||||
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
|
||||
if (boundary <= 0) return -1;
|
||||
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
|
||||
if (markerIndex <= 0) return false;
|
||||
|
||||
const marker = lineText[markerIndex];
|
||||
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
|
||||
|
||||
const prev = lineText[markerIndex - 1];
|
||||
return !/[\s~:\])}]/.test(prev);
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
|
||||
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
|
||||
}
|
||||
|
||||
function canSupersedeThemedPromptBoundary(
|
||||
lineText: string,
|
||||
previousBoundary: number,
|
||||
markerIndex: number,
|
||||
): boolean {
|
||||
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
|
||||
|
||||
const rawBetween = lineText.slice(previousBoundary, markerIndex);
|
||||
const between = rawBetween.trim();
|
||||
return (
|
||||
between.length === 0 ||
|
||||
isPromptPathDecoration(between) ||
|
||||
isPromptStatusText(between) ||
|
||||
(
|
||||
/^\s/.test(rawBetween) &&
|
||||
isPromptBareDirectoryText(between)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
|
||||
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "›";
|
||||
}
|
||||
|
||||
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
|
||||
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
|
||||
if (markerIndex <= 0) return false;
|
||||
if (lineText[markerIndex - 1] !== " ") return false;
|
||||
return lineText[markerIndex + 1] === " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
|
||||
@@ -193,6 +789,15 @@ function findPromptBoundary(lineText: string): number {
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
if (
|
||||
(ch === ">" || ch === "›") &&
|
||||
lastStandardBoundary >= 0 &&
|
||||
/\s/.test(lineText.slice(0, i).trim()) &&
|
||||
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
|
||||
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Must be followed by a space or end-of-line.
|
||||
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
@@ -252,6 +857,31 @@ function findPromptBoundary(lineText: string): number {
|
||||
// Record this as a candidate boundary. A standard shell prompt terminator
|
||||
// is more reliable than a later Powerline/Nerd Font glyph in command text.
|
||||
const boundary = nextChar === " " ? i + 2 : i + 1;
|
||||
const candidatePromptText = lineText.slice(0, boundary);
|
||||
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
|
||||
continue;
|
||||
}
|
||||
if (isStandard && lastStandardBoundary >= 0) {
|
||||
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
|
||||
lineText,
|
||||
lastStandardBoundary,
|
||||
getBoundaryMarkerIndex(lineText, boundary),
|
||||
);
|
||||
const canSupersedePreviousBoundary =
|
||||
canPromptMarkerSupersedePreviousBoundary(ch) &&
|
||||
(
|
||||
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
|
||||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
|
||||
themedPromptCanSupersede
|
||||
) &&
|
||||
(
|
||||
themedPromptCanSupersede ||
|
||||
isSpecificShellPromptCandidate(candidatePromptText, {
|
||||
allowGreaterThanTerminator: ch === ">" || ch === "›",
|
||||
})
|
||||
);
|
||||
if (!canSupersedePreviousBoundary) continue;
|
||||
}
|
||||
if (isStandard) {
|
||||
lastStandardBoundary = boundary;
|
||||
} else {
|
||||
@@ -291,6 +921,11 @@ export function reconcilePromptWithTypedInput(
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
if (!canReconcilePromptDecoration(prompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return prompt;
|
||||
}
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
@@ -302,6 +937,36 @@ export function reconcilePromptWithTypedInput(
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function reconcilePromptWithExternalCommand(
|
||||
prompt: PromptDetectionResult,
|
||||
command: string,
|
||||
): PromptDetectionResult | null {
|
||||
const typedInput = command.trim();
|
||||
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...prompt,
|
||||
userInput: `${prompt.userInput}${typedInput}`,
|
||||
cursorOffset: prompt.userInput.length + typedInput.length,
|
||||
};
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = syntheticPrompt.userInput.slice(
|
||||
0,
|
||||
syntheticPrompt.userInput.length - typedInput.length,
|
||||
);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
@@ -312,13 +977,11 @@ export function reconcilePromptWithTypedInput(
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable AND the raw detected prompt still looks like the same shell
|
||||
* line. When the raw buffer has either over-captured prompt chrome
|
||||
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
|
||||
* shell echo/render is lagging behind local keystrokes
|
||||
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
|
||||
* Otherwise the buffer is ignored and the raw detector result passes
|
||||
* through.
|
||||
* reliable and it can be reconciled with the live line. Exact raw
|
||||
* matches are safe, over-captured prompt chrome can be moved back into
|
||||
* promptText, and no-space prompts can be inferred from the cursor line
|
||||
* when the inferred prompt still looks like a shell prompt. Otherwise
|
||||
* the buffer is ignored and the raw detector result passes through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
@@ -327,38 +990,40 @@ export function getAlignedPrompt(
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
|
||||
if (!typedReliable || typedBuffer.length === 0) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
return {
|
||||
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
|
||||
return {
|
||||
prompt: replacePromptUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const cursorLinePrefix = getCursorLinePrefix(term);
|
||||
if (cursorLinePrefix?.endsWith(typedBuffer)) {
|
||||
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
|
||||
if (promptText.length > 0) {
|
||||
|
||||
if (raw.isAtPrompt) {
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
|
||||
if (prompt === raw) return { prompt: raw, alignedTyped: null };
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
prompt,
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
|
||||
if (themedDecorationAlignment) return themedDecorationAlignment;
|
||||
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
|
||||
return {
|
||||
prompt: withTypedUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cursorPrefixCandidates = [
|
||||
getWrappedCursorPrefix(term),
|
||||
getCursorLinePrefix(term),
|
||||
];
|
||||
for (const cursorPrefix of cursorPrefixCandidates) {
|
||||
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
|
||||
if (aligned) return aligned;
|
||||
}
|
||||
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { GhostTextAddon } from "./GhostTextAddon";
|
||||
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { shellEscape } from "./completionEngine";
|
||||
@@ -19,6 +24,7 @@ import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -107,6 +113,96 @@ export interface TerminalAutocompleteHandle {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
function hasStandardShellPromptTerminator(promptText: string): boolean {
|
||||
return /[$#%>]$/.test(promptText.trimEnd());
|
||||
}
|
||||
|
||||
function isSingleThemedPromptTerminator(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length !== 1) return false;
|
||||
const code = trimmed.charCodeAt(0);
|
||||
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
|
||||
}
|
||||
|
||||
function isThemedPromptPathToken(token: string): boolean {
|
||||
return (
|
||||
token === "~" ||
|
||||
token.startsWith("~/") ||
|
||||
token.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token) ||
|
||||
token.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
|
||||
const hasThemedPromptMarker =
|
||||
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
|
||||
Array.from(prompt.promptText).some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
});
|
||||
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
|
||||
return false;
|
||||
}
|
||||
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
|
||||
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
|
||||
return (
|
||||
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
|
||||
/\S+\s+\S/.test(prompt.userInput)
|
||||
);
|
||||
}
|
||||
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
|
||||
}
|
||||
|
||||
export function getCommandToRecordOnEnter(
|
||||
livePrompt: PromptDetectionResult,
|
||||
alignedTyped: string | null,
|
||||
typedBuffer: string,
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
|
||||
if (reliableTypedCommand) {
|
||||
const reconciledPrompt = reconcilePromptWithExternalCommand(
|
||||
livePrompt,
|
||||
reliableTypedCommand,
|
||||
);
|
||||
if (reconciledPrompt) return reliableTypedCommand;
|
||||
}
|
||||
|
||||
const liveCommand = livePrompt.userInput.trim();
|
||||
if (!liveCommand && reliableTypedCommand) {
|
||||
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
|
||||
? null
|
||||
: reliableTypedCommand;
|
||||
}
|
||||
if (!liveCommand) return null;
|
||||
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
const liveInputMayIncludePromptDecoration =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
liveCommand !== typedBuffer.trim() &&
|
||||
liveCommand.endsWith(typedBuffer.trim());
|
||||
if (liveInputMayIncludePromptDecoration) return null;
|
||||
|
||||
const liveInputMayBeLagging =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
typedBuffer.length > livePrompt.userInput.length &&
|
||||
typedBuffer.startsWith(livePrompt.userInput);
|
||||
if (liveInputMayBeLagging) return null;
|
||||
|
||||
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
export function useTerminalAutocomplete(
|
||||
options: UseTerminalAutocompleteOptions,
|
||||
): TerminalAutocompleteHandle {
|
||||
@@ -158,6 +254,10 @@ export function useTerminalAutocomplete(
|
||||
const fetchVersionRef = useRef(0);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -441,6 +541,41 @@ export function useTerminalAutocomplete(
|
||||
});
|
||||
}, [termRef]);
|
||||
|
||||
/**
|
||||
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
|
||||
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
|
||||
*/
|
||||
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term, typedInputBufferRef.current, typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const parsed = parseCommandLine(prompt.userInput);
|
||||
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
|
||||
+ (parsed.wordIndex > 0 ? " " : "");
|
||||
const currentToken = parsed.currentWord;
|
||||
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
|
||||
? currentToken[0] : "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name : shellEscape(entry.name);
|
||||
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Handle selecting a file/directory from any sub-dir panel.
|
||||
* Builds the full path from the panel stack and replaces the current input. */
|
||||
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
|
||||
@@ -571,6 +706,9 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
@@ -643,29 +781,21 @@ export function useTerminalAutocomplete(
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const typedBuffer = typedInputBufferRef.current;
|
||||
const typedBufferReliable = typedBufferReliableRef.current;
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (livePrompt.isAtPrompt) {
|
||||
// alignedTyped is only non-null when the buffer is reliable
|
||||
// AND matches the live line's tail — that single signal
|
||||
// covers both the robbyrussell "~ " case (#806) and the
|
||||
// stale-buffer cases from out-of-band pastes / history
|
||||
// recall (#814 P1/P2). When it's null we fall back to the
|
||||
// reconciled livePrompt.userInput, which for paste-bypass
|
||||
// scenarios lands on pre-PR behavior (no regression).
|
||||
if (alignedTyped && alignedTyped.trim()) {
|
||||
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
|
||||
} else if (livePrompt.userInput.trim()) {
|
||||
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
|
||||
// Only fall back to the cached prompt when we have no live
|
||||
// reading at all — guards against recording during interactive
|
||||
// prompts where detectPrompt correctly bails out.
|
||||
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
const commandToRecord = getCommandToRecordOnEnter(
|
||||
livePrompt,
|
||||
alignedTyped,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (commandToRecord) {
|
||||
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
@@ -789,6 +919,10 @@ export function useTerminalAutocomplete(
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
@@ -968,10 +1102,11 @@ export function useTerminalAutocomplete(
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
@@ -1000,8 +1135,10 @@ export function useTerminalAutocomplete(
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
@@ -1057,39 +1194,37 @@ export function useTerminalAutocomplete(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation
|
||||
if (e.key === "ArrowUp") {
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
|
||||
renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
if (s.selectedIndex >= 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
insertSuggestion(selected, true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,8 +1233,12 @@ export function useTerminalAutocomplete(
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1109,6 +1248,36 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
*/
|
||||
const renderPreviewSelection = useCallback((index: number) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const baseline = previewBaselineRef.current;
|
||||
const candidate =
|
||||
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput,
|
||||
candidate,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = candidate;
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/**
|
||||
* Insert a suggestion into the terminal.
|
||||
* @param execute If true, also sends \r to execute the command.
|
||||
|
||||
45
components/terminal/ghostTextConsistency.test.ts
Normal file
45
components/terminal/ghostTextConsistency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
|
||||
|
||||
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
|
||||
});
|
||||
|
||||
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
|
||||
// Tracked is one char behind what the device actually shows.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
|
||||
});
|
||||
|
||||
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
|
||||
// The tracked input hasn't been fully echoed yet — reality being behind
|
||||
// never corrupts, so the ghost must stay.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
|
||||
});
|
||||
|
||||
test("ignores trailing whitespace after the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
|
||||
});
|
||||
|
||||
test("hides when untracked non-space input follows the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
|
||||
});
|
||||
|
||||
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
|
||||
// Prompt contains 'int'; the real typed 'int' is the one at the end.
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
|
||||
});
|
||||
|
||||
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
|
||||
});
|
||||
|
||||
test("skips single-character input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
|
||||
});
|
||||
|
||||
test("returns false when the tracked input isn't on the line yet (latency)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
|
||||
});
|
||||
45
components/terminal/livePreviewSequence.test.ts
Normal file
45
components/terminal/livePreviewSequence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
|
||||
|
||||
test("appends only the tail when the candidate continues the current line", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
|
||||
"cker",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty when the line already equals the candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
|
||||
"\x15df",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears when switching to a shorter prefix candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
|
||||
"\x15docker",
|
||||
);
|
||||
});
|
||||
|
||||
test("reverting to the typed baseline clears then rewrites the baseline", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
|
||||
"\x15do",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
|
||||
"\b\b\bxy",
|
||||
);
|
||||
});
|
||||
@@ -270,7 +270,7 @@ const writeSessionData = (
|
||||
) => {
|
||||
enqueueTerminalWrite(term, (done) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? true;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
|
||||
if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) {
|
||||
ctx.promptLineBreakStateRef.current.pendingCommand = false;
|
||||
ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false;
|
||||
@@ -377,6 +377,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const scheduleStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
id: string,
|
||||
onSettled?: () => void,
|
||||
): (() => void) | undefined => {
|
||||
@@ -395,7 +396,7 @@ const scheduleStartupCommand = (
|
||||
automated: true,
|
||||
});
|
||||
if (!ctx.noAutoRun) {
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, commandToRun);
|
||||
}
|
||||
onSettled?.();
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
@@ -877,7 +878,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
|
||||
// Run OS detection only after successful connection. Mint a fresh
|
||||
// token for this specific connection attempt and register it as
|
||||
@@ -991,7 +992,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.sessionId,
|
||||
() => {
|
||||
disposeAutoLoginListener();
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = undefined;
|
||||
disposeAutoLoginCancelListener();
|
||||
});
|
||||
@@ -1158,7 +1159,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
|
||||
@@ -4,6 +4,50 @@ import assert from "node:assert/strict";
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||||
|
||||
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("command execution arms prompt line break even without command history callback", () => {
|
||||
const promptState = createPromptLineBreakState();
|
||||
const commandBufferRef = { current: "echo ok" };
|
||||
@@ -12,8 +56,6 @@ test("command execution arms prompt line break even without command history call
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
@@ -23,3 +65,332 @@ test("command execution arms prompt line break even without command history call
|
||||
assert.equal(commandBufferRef.current, "");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution caches the current prompt instead of prompt-like command text", () => {
|
||||
const promptState = createPromptLineBreakState();
|
||||
const commandBufferRef = { current: "echo > out" };
|
||||
|
||||
recordTerminalCommandExecution("echo > out", {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
}, createFakeTerm("$ echo > out") as never);
|
||||
|
||||
assert.equal(promptState.lastPromptText, "$ ");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution does not write interactive program input to shell history", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, "", lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record interactive input before echo appears", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "rs.status()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record wrapped interactive program input", () => {
|
||||
const cases = [
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
||||
const commands = ["deploy", "exit", "help", "show dbs"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help" },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs" },
|
||||
{ rows: ["admin> ex", "it"], command: "exit" },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records short commands when standard prompt echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls" },
|
||||
{ lineText: "prod> l", command: "ls" },
|
||||
{ lineText: "prod.web> l", command: "ls" },
|
||||
{ lineText: "user@host:~$ l", command: "ls" },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls" },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls" },
|
||||
{ lineText: "➜ git l", command: "ls" },
|
||||
{ lineText: "➜ git np", command: "npm" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from themed bare directory prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, promptText, lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution still records host-style greater-than prompts", () => {
|
||||
const prompts = [
|
||||
"prod-web> ",
|
||||
"prod> ",
|
||||
"prod.web> ",
|
||||
"server> ",
|
||||
"staging> ",
|
||||
"webdb> ",
|
||||
"prod.db> ",
|
||||
];
|
||||
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from host-style greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,8 +43,10 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
@@ -78,6 +80,13 @@ export type XTermRuntime = {
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
/**
|
||||
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
|
||||
* next frame. No-op when the DOM renderer is active. Used to recover from the
|
||||
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
|
||||
* fall into after font changes or device pixel ratio changes.
|
||||
*/
|
||||
clearTextureAtlas: () => void;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -157,6 +166,15 @@ const detectPlatform = (): XTermPlatform => {
|
||||
return "darwin";
|
||||
};
|
||||
|
||||
const csiParamsInclude = (
|
||||
params: readonly (number | number[])[],
|
||||
target: number,
|
||||
): boolean => params.some((param) => (
|
||||
Array.isArray(param)
|
||||
? param.includes(target)
|
||||
: param === target
|
||||
));
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
@@ -376,6 +394,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
|
||||
// (claude code / gemini cli / opencode and other full-screen agents), font
|
||||
// changes, and device pixel ratio changes can leave that atlas in a corrupted
|
||||
// state that persists for the life of the terminal — the "garbled / 花屏"
|
||||
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
|
||||
// the atlas forces glyphs to re-rasterize at the correct scale on the next
|
||||
// frame. No-op for the DOM renderer.
|
||||
const clearWebglTextureAtlas = () => {
|
||||
if (!webglAddon) return;
|
||||
try {
|
||||
webglAddon.clearTextureAtlas();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] clearTextureAtlas failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Recover the renderer when the device pixel ratio changes (moving the window
|
||||
// between monitors with different DPI, or changing OS display scaling — a
|
||||
// common Windows trigger). matchMedia change does not fire a normal resize, so
|
||||
// this is needed in addition to the resize handling below.
|
||||
let stopDprWatch: () => void = () => {};
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
) {
|
||||
stopDprWatch = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: () => window.devicePixelRatio || 1,
|
||||
matchMedia: (query) => window.matchMedia(query),
|
||||
onChange: () => {
|
||||
clearWebglTextureAtlas();
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||||
let shouldOpen = false;
|
||||
@@ -514,7 +571,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.onAutocompleteInput?.(snippetData);
|
||||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||||
if (!snippet.noAutoRun) {
|
||||
recordTerminalCommandExecution(snippet.command, ctx);
|
||||
recordTerminalCommandExecution(snippet.command, ctx, term);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -687,7 +744,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
if (ctx.statusRef.current === "connected") {
|
||||
if (data === "\r" || data === "\n") {
|
||||
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx);
|
||||
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x03") {
|
||||
@@ -721,6 +778,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
|
||||
if (csiParamsInclude(params, 6)) {
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cursorPositionReportRequestDisposables = [
|
||||
term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
|
||||
term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
|
||||
];
|
||||
|
||||
const writeKittyKeyboardReply = (payload: string) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
@@ -836,6 +905,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
@@ -854,10 +926,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
clearTextureAtlas: clearWebglTextureAtlas,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
kittyKeyboardDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
insertPromptLineBreakBeforePrompt,
|
||||
markPromptLineBreakCommandPending,
|
||||
prepareTerminalDataForPromptLineBreak,
|
||||
syncPromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
@@ -29,6 +30,29 @@ function createFakeTerm(lineText = "", cursorX = lineText.length) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("does not insert before prompt-like suffixes in a larger output chunk", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0),
|
||||
@@ -71,6 +95,56 @@ test("does not insert for output chunks that only end with the cached prompt tex
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before an ambiguous prompt suffix inside output", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("world$ ", "$ ", 5),
|
||||
"world$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before prompt-like output after a line break", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("\r\nhello$ ", "$ ", 0),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct root prompt in the same output chunk", () => {
|
||||
const prompt = "[root@iZwz9ftrhzy4b3hduolf6yZ ~]# ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct conda prompt in the same output chunk", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct no-space root prompt in the same output chunk", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before an already separated distinct prompt", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail\r\n${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not refresh cached prompt from output that only ends with the prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
@@ -90,10 +164,724 @@ test("does not refresh cached prompt from output that only ends with the prompt
|
||||
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, false);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("keeps waiting for the real prompt after an output suffix matches the prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"total $ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"total $ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("total $ ", 8) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps waiting after prompt-like output on a fresh line", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"\r\nhello$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("hello$ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("hello$ ", 7) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepares a same-chunk cat output break for a distinct prompt", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "(base) rynn@aiserver:~$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"without trailing newline(base) rynn@aiserver:~$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"without trailing newline\r\n(base) rynn@aiserver:~$ ",
|
||||
);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt from typed command alignment", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command.slice(0, -1)}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags by a word", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}printf `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a longer command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags mid-word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git st`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags near completion", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ git statu") as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags after a word boundary", () => {
|
||||
const cases = ["$ git ", "$ git st"];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a standard prompt when short command echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls", promptText: "prod-web> " },
|
||||
{ lineText: "prod> l", command: "ls", promptText: "prod> " },
|
||||
{ lineText: "prod.web> l", command: "ls", promptText: "prod.web> " },
|
||||
{ lineText: "user@host:~$ l", command: "ls", promptText: "user@host:~$ " },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls", promptText: "[user@host ~]$ " },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls", promptText: "➜ netcatty $ " },
|
||||
{ lineText: "➜ git l", command: "ls", promptText: "➜ git " },
|
||||
{ lineText: "➜ git np", command: "npm", promptText: "➜ git " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText = "$ " } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "ls ", command: "ls -la" },
|
||||
{ echoedInput: "cd ", command: "cd /tmp" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by one character", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "l", command: "ls" },
|
||||
{ echoedInput: "c", command: "cd" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ ls") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "ftp> ls", command: "ls" },
|
||||
{ lineText: "ghci> :t map", command: ":t map" },
|
||||
{ lineText: "node> .help", command: ".help" },
|
||||
{ lineText: "mongo> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "cqlsh> select * from users", command: "select * from users" },
|
||||
{ lineText: "hive> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql> select 1", command: "select 1" },
|
||||
{ lineText: "jshell> /help", command: "/help" },
|
||||
{ lineText: " ...> System.out.println(1)", command: "System.out.println(1)" },
|
||||
{ lineText: "ksql> select 1", command: "select 1" },
|
||||
{ lineText: "trino> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "presto> show catalogs", command: "show catalogs" },
|
||||
{ lineText: "presto:default> show tables", command: "show tables" },
|
||||
{ lineText: "duckdb> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql (default)> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "test> 1 + 1", command: "1 + 1" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache wrapped common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["sftp> get very-long-", "remote-file"], command: "get very-long-remote-file" },
|
||||
{ rows: ["node> console.", "log('ok')"], command: "console.log('ok')" },
|
||||
{ rows: ["mongo> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["cqlsh> select *", " from users"], command: "select * from users" },
|
||||
{ rows: ["jshell> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: [" ...> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: ["trino> select", " 1"], command: "select 1" },
|
||||
{ rows: ["trino:tpch> select", " 1"], command: "select 1" },
|
||||
{ rows: ["duckdb> select", " 1"], command: "select 1" },
|
||||
{ rows: ["cqlsh:cycling> select *", " from cyclist"], command: "select * from cyclist" },
|
||||
{ rows: ["hive (default)> select", " 1"], command: "select 1" },
|
||||
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], command: "select 1" },
|
||||
{ rows: ["test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
{ rows: ["rs0:PRIMARY> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], command: "const x = 1" },
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 primary test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> print", "(1)"], command: "print(1)" },
|
||||
{ rows: ["test> 1 ", "+ 1"], command: "1 + 1" },
|
||||
{ rows: ["admin@localhost:27017> db.", "stats()"], command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help", promptText: "test> " },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs", promptText: "test> " },
|
||||
{ rows: ["admin> ex", "it"], command: "exit", promptText: "admin> " },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy", promptText: "local> " },
|
||||
];
|
||||
|
||||
for (const { rows, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live command suffix as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ echo sudo") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache host prompt command symbols as prompt text", () => {
|
||||
const prompt = "user@host:~$ ";
|
||||
const cases = [
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a themed prompt live command suffix as prompt text", () => {
|
||||
for (const lineText of [
|
||||
"➜ ~ echo sudo",
|
||||
"➜ echo sudo",
|
||||
"➜ make sudo",
|
||||
"➜ docker sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ~ echo # sudo",
|
||||
]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations from typed command alignment", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~/repo do", command: "do", promptText: "➜ ~/repo " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ + ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ + ",
|
||||
},
|
||||
{ lineText: "➜ netcatty ✗ $ ls", command: "ls", promptText: "➜ netcatty ✗ $ " },
|
||||
{ lineText: "➜ netcatty $ ls", command: "ls", promptText: "➜ netcatty $ " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations when command echo lags", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~ git ", command: "git status", promptText: "➜ ~ " },
|
||||
{ lineText: "➜ ~ git st", command: "git status", promptText: "➜ ~ " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git ",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git st",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed bare directory prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache interactive prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("clears an old cached prompt when a direct send is interactive", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "rs0 [direct: primary] reporting> ";
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("rs0 [direct: primary] reporting> ") as never,
|
||||
"db.stats()",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches host-style greater-than prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, lineText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live path suffix as prompt text", () => {
|
||||
for (const lineText of ["$ cd ~/sudo", "$ cat > sudo", "$ echo path#sudo"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command from a standard prompt echo prefix", () => {
|
||||
for (const lineText of ["$ s", "$ su", "$ sud"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache partial stale commands after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
for (const lineText of [`${prompt}s`, `${prompt}sud`]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache stale command suffixes after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
`${prompt}cat > sudo`,
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo path#sudo`,
|
||||
`${prompt}> sudo`,
|
||||
`${prompt}# sudo`,
|
||||
`${prompt}% sudo`,
|
||||
`${prompt}$ sudo`,
|
||||
];
|
||||
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs prompts that contain prompt-like symbols", () => {
|
||||
const prompts = [
|
||||
"user@host ~/foo# bar $ ",
|
||||
"user@host ~/foo# git $ ",
|
||||
"user@host ~/foo#git $ ",
|
||||
"root@host ~/foo# bar # ",
|
||||
"root@host ~/foo#bar # ",
|
||||
"fish@host ~/foo# bar % ",
|
||||
"fish@host ~/foo%bar % ",
|
||||
"user@host:~/foo# bar $ ",
|
||||
"user@host ~/repo # $ ",
|
||||
"➜ ~ $ ",
|
||||
"user@host ~/foo% bar $ ",
|
||||
"user@host ~/foo> bar $ ",
|
||||
"user@host ~/foo# bar> ",
|
||||
"user@host ~/foo# bar› ",
|
||||
"user@host ~/foo#bar> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(prompt) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, prompt);
|
||||
assert.equal(state.pendingCommand, false, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs a no-space root prompt without xterm row padding", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(`${prompt} `, prompt.length) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, false);
|
||||
});
|
||||
|
||||
test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "old$ ";
|
||||
@@ -148,6 +936,6 @@ test("does not refresh cached prompt from an unchanged mid-line write without a
|
||||
syncPromptLineBreakState(createFakeTerm("outputnew$ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "old$ ");
|
||||
assert.equal(state.pendingCommand, false);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
import { detectPrompt } from "../autocomplete/promptDetector";
|
||||
import {
|
||||
detectPrompt,
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
} from "../autocomplete/promptDetector";
|
||||
|
||||
export type PromptLineBreakState = {
|
||||
lastPromptText: string;
|
||||
@@ -86,6 +91,12 @@ const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean =>
|
||||
return prefixText.length > 0 && !endsWithLineBreak(prefixText);
|
||||
};
|
||||
|
||||
const isDistinctPromptText = (promptText: string): boolean => {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length >= 8) return true;
|
||||
return trimmed.length >= 6 && /[@:\\/]/.test(trimmed);
|
||||
};
|
||||
|
||||
const getCursorX = (term: XTerm): number => {
|
||||
try {
|
||||
return term.buffer.active.cursorX;
|
||||
@@ -104,12 +115,71 @@ export function createPromptLineBreakState(): PromptLineBreakState {
|
||||
|
||||
export function markPromptLineBreakCommandPending(
|
||||
stateRef?: RefObject<PromptLineBreakState>,
|
||||
term?: XTerm | null,
|
||||
command?: string,
|
||||
): void {
|
||||
if (!stateRef?.current) return;
|
||||
if (term) {
|
||||
const cachedFromCommand = command
|
||||
? cachePromptLineBreakPromptFromCommand(term, stateRef.current, command)
|
||||
: false;
|
||||
if (!cachedFromCommand) {
|
||||
cachePromptLineBreakPrompt(term, stateRef.current);
|
||||
}
|
||||
}
|
||||
stateRef.current.pendingCommand = true;
|
||||
stateRef.current.suppressNextPromptCache = false;
|
||||
}
|
||||
|
||||
function cachePromptLineBreakPromptFromCommand(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
command: string,
|
||||
): boolean {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!state || trimmedCommand.length === 0) return false;
|
||||
|
||||
const aligned = getAlignedPrompt(term, trimmedCommand, true);
|
||||
if (!aligned.prompt.isAtPrompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
if (isNonPromptLine(`${aligned.prompt.promptText}${trimmedCommand}`)) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
aligned.alignedTyped === trimmedCommand
|
||||
? aligned.prompt
|
||||
: reconcilePromptWithExternalCommand(aligned.prompt, trimmedCommand);
|
||||
if (!prompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cachePromptLineBreakPrompt(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
): void {
|
||||
if (!state) return;
|
||||
|
||||
const prompt = detectPrompt(term);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
if (prompt.userInput.length > 0) return;
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
}
|
||||
|
||||
export function insertPromptLineBreakBeforePrompt(
|
||||
data: string,
|
||||
promptText: string,
|
||||
@@ -123,7 +193,10 @@ export function insertPromptLineBreakBeforePrompt(
|
||||
const promptTextStart = mapped.text.length - promptText.length;
|
||||
const prefixText = mapped.text.slice(0, promptTextStart);
|
||||
if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data;
|
||||
if (prefixText.length > 0) return data;
|
||||
if (prefixText.length > 0) {
|
||||
if (endsWithLineBreak(prefixText)) return data;
|
||||
if (!isDistinctPromptText(promptText)) return data;
|
||||
}
|
||||
|
||||
const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0;
|
||||
return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`;
|
||||
@@ -144,11 +217,11 @@ export function prepareTerminalDataForPromptLineBreak(
|
||||
cursorXBeforeWrite,
|
||||
);
|
||||
const visibleText = mapVisibleText(data).text;
|
||||
const ambiguousPromptSuffix = hasAmbiguousPromptSuffix(data, state.lastPromptText);
|
||||
state.suppressNextPromptCache =
|
||||
nextData === data &&
|
||||
(cursorXBeforeWrite > 0 ||
|
||||
hasAmbiguousPromptSuffix(data, state.lastPromptText)) &&
|
||||
!containsLineReset(visibleText);
|
||||
(ambiguousPromptSuffix ||
|
||||
(cursorXBeforeWrite > 0 && !containsLineReset(visibleText)));
|
||||
return nextData;
|
||||
}
|
||||
|
||||
@@ -160,7 +233,6 @@ export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakSta
|
||||
|
||||
if (state.pendingCommand && state.suppressNextPromptCache) {
|
||||
state.suppressNextPromptCache = false;
|
||||
state.pendingCommand = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type MediaQueryListLike,
|
||||
watchDevicePixelRatio,
|
||||
} from "./rendererDprWatch";
|
||||
|
||||
class FakeMediaQueryList implements MediaQueryListLike {
|
||||
readonly query: string;
|
||||
modernListeners: Array<() => void> = [];
|
||||
legacyListeners: Array<() => void> = [];
|
||||
private readonly supportsModern: boolean;
|
||||
|
||||
constructor(query: string, supportsModern = true) {
|
||||
this.query = query;
|
||||
this.supportsModern = supportsModern;
|
||||
if (!supportsModern) {
|
||||
// Strip the modern API to emulate legacy environments.
|
||||
this.addEventListener = undefined;
|
||||
this.removeEventListener = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners.push(listener);
|
||||
};
|
||||
|
||||
removeEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
addListener = (listener: () => void) => {
|
||||
this.legacyListeners.push(listener);
|
||||
};
|
||||
|
||||
removeListener = (listener: () => void) => {
|
||||
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
trigger() {
|
||||
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
|
||||
}
|
||||
|
||||
get listenerCount() {
|
||||
return this.modernListeners.length + this.legacyListeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(initialDpr: number, supportsModern = true) {
|
||||
let dpr = initialDpr;
|
||||
const created: FakeMediaQueryList[] = [];
|
||||
return {
|
||||
created,
|
||||
getDevicePixelRatio: () => dpr,
|
||||
matchMedia: (query: string) => {
|
||||
const mql = new FakeMediaQueryList(query, supportsModern);
|
||||
created.push(mql);
|
||||
return mql;
|
||||
},
|
||||
setDpr: (value: number) => {
|
||||
dpr = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registers a change listener for the current devicePixelRatio", () => {
|
||||
const env = makeEnv(1);
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(env.created.length, 1);
|
||||
assert.equal(env.created[0].query, "(resolution: 1dppx)");
|
||||
assert.equal(env.created[0].listenerCount, 1);
|
||||
});
|
||||
|
||||
test("invokes onChange when the media query reports a change", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("re-registers for the new ratio so subsequent changes still fire", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(env.created.length, 2);
|
||||
assert.equal(env.created[1].query, "(resolution: 2dppx)");
|
||||
// The stale listener must be detached so it cannot double-fire.
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
|
||||
env.setDpr(3);
|
||||
env.created[1].trigger();
|
||||
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("cleanup stops further onChange callbacks", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
|
||||
const env = makeEnv(1, /* supportsModern */ false);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.created[0].legacyListeners.length, 1);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 1);
|
||||
|
||||
stop();
|
||||
// After cleanup the most recently registered query has no listeners.
|
||||
const latest = env.created[env.created.length - 1];
|
||||
assert.equal(latest.listenerCount, 0);
|
||||
});
|
||||
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
|
||||
* with different DPI, or changing the OS display scaling on Windows) and invokes
|
||||
* a callback so the renderer can be repaired.
|
||||
*
|
||||
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
|
||||
* device pixel ratio at creation time. When the ratio changes the cached glyphs
|
||||
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
|
||||
* corruption reported in issue #1049 that only goes away when a brand-new
|
||||
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
|
||||
* change so glyphs re-rasterize at the new scale.
|
||||
*
|
||||
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
|
||||
* change we must re-register the listener against the new ratio.
|
||||
*/
|
||||
export interface MediaQueryListLike {
|
||||
addEventListener?: (type: "change", listener: () => void) => void;
|
||||
removeEventListener?: (type: "change", listener: () => void) => void;
|
||||
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
}
|
||||
|
||||
export interface WatchDevicePixelRatioOptions {
|
||||
getDevicePixelRatio: () => number;
|
||||
matchMedia: (query: string) => MediaQueryListLike;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for devicePixelRatio changes. Returns a cleanup function that
|
||||
* removes the active listener.
|
||||
*/
|
||||
export function watchDevicePixelRatio(
|
||||
options: WatchDevicePixelRatioOptions,
|
||||
): () => void {
|
||||
const { getDevicePixelRatio, matchMedia, onChange } = options;
|
||||
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
|
||||
|
||||
const detach = () => {
|
||||
if (!current) return;
|
||||
const { mql, listener } = current;
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener("change", listener);
|
||||
} else if (mql.removeListener) {
|
||||
mql.removeListener(listener);
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
const dpr = getDevicePixelRatio();
|
||||
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
|
||||
const listener = () => {
|
||||
// A media query only matches the ratio it was created with, so detach the
|
||||
// stale listener and re-register against the new ratio before notifying.
|
||||
detach();
|
||||
attach();
|
||||
onChange();
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", listener);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(listener);
|
||||
}
|
||||
current = { mql, listener };
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return detach;
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Host } from "../../../types";
|
||||
import {
|
||||
markPromptLineBreakCommandPending,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
} from "../autocomplete/promptDetector";
|
||||
|
||||
type TerminalCommandExecutionContext = {
|
||||
host: Pick<Host, "id" | "label">;
|
||||
@@ -18,14 +24,34 @@ type TerminalCommandExecutionContext = {
|
||||
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
|
||||
};
|
||||
|
||||
const shouldRecordShellHistory = (
|
||||
command: string,
|
||||
term?: XTerm | null,
|
||||
): boolean => {
|
||||
if (!term) return true;
|
||||
|
||||
const { prompt, alignedTyped } = getAlignedPrompt(term, command, true);
|
||||
if (!prompt.isAtPrompt) return false;
|
||||
if (alignedTyped?.trim() === command.trim()) return true;
|
||||
|
||||
if (reconcilePromptWithExternalCommand(prompt, command)) return true;
|
||||
|
||||
const liveCommand = prompt.userInput.trim();
|
||||
if (liveCommand.length === 0) {
|
||||
return !isNonPromptLine(`${prompt.promptText}${command.trim()}`);
|
||||
}
|
||||
return liveCommand === command.trim();
|
||||
};
|
||||
|
||||
export const recordTerminalCommandExecution = (
|
||||
command: string,
|
||||
ctx: TerminalCommandExecutionContext,
|
||||
term?: XTerm | null,
|
||||
) => {
|
||||
const cmd = command.trim();
|
||||
if (cmd) {
|
||||
if (cmd && shouldRecordShellHistory(cmd, term)) {
|
||||
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
ctx.commandBufferRef.current = "";
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, command);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
clearPasteResidualAfterTerminalWrite,
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
prepareTerminalDataForUserPasteDisplay,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
@@ -151,6 +152,95 @@ test("broadcast gate consumes paste state even when broadcast is disabled before
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate suppresses expected terminal cursor position report replies", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate suppresses cursor position report replies split across chunks", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "24;", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate preserves normal input while a cursor position report is pending", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls\r", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate preserves keyboard sequences that look like cursor reports without a terminal request", () => {
|
||||
const term = {};
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;2R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("user paste preserves the existing scroll-on-paste behavior", () => {
|
||||
const calls: string[] = [];
|
||||
const term = {
|
||||
|
||||
@@ -29,12 +29,20 @@ type PasteInputScrollState = {
|
||||
remainingDataVariants: string[];
|
||||
};
|
||||
|
||||
type TerminalProtocolReplyState = {
|
||||
expiresAt: number;
|
||||
pendingCursorPositionReports: number;
|
||||
cursorPositionReportFragment: string;
|
||||
};
|
||||
|
||||
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
|
||||
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const terminalProtocolReplyStates = new WeakMap<object, TerminalProtocolReplyState>();
|
||||
const LONG_PASTE_MIN_LENGTH = 200;
|
||||
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
|
||||
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
|
||||
const TERMINAL_PROTOCOL_REPLY_WINDOW_MS = 4000;
|
||||
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
|
||||
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
@@ -116,6 +124,45 @@ const getPlainTerminalText = (data: string): string =>
|
||||
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
|
||||
type CursorPositionReportMatch =
|
||||
| { type: "complete"; length: number }
|
||||
| { type: "prefix" }
|
||||
| { type: "none" };
|
||||
|
||||
const isAsciiDigit = (char: string): boolean => char >= "0" && char <= "9";
|
||||
|
||||
const matchCursorPositionReportFromStart = (data: string): CursorPositionReportMatch => {
|
||||
if (!data.startsWith(ESC)) return { type: "none" };
|
||||
if (data.length === 1) return { type: "prefix" };
|
||||
if (data[1] !== "[") return { type: "none" };
|
||||
if (data.length === 2) return { type: "prefix" };
|
||||
|
||||
let index = 2;
|
||||
if (data[index] === "?") {
|
||||
index += 1;
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
}
|
||||
|
||||
let rowDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
rowDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (rowDigits === 0 || data[index] !== ";") return { type: "none" };
|
||||
|
||||
index += 1;
|
||||
let columnDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
columnDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (columnDigits === 0 || data[index] !== "R") return { type: "none" };
|
||||
|
||||
return { type: "complete", length: index + 1 };
|
||||
};
|
||||
|
||||
const getPasteEchoFragments = (text: string): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
@@ -304,13 +351,81 @@ export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data:
|
||||
return consumePasteInputState(pasteBroadcastStates, term, data);
|
||||
}
|
||||
|
||||
export function markExpectedTerminalCursorPositionReport(term: object): void {
|
||||
const currentState = terminalProtocolReplyStates.get(term);
|
||||
const activeState = isStateActive(currentState)
|
||||
? currentState
|
||||
: {
|
||||
expiresAt: 0,
|
||||
pendingCursorPositionReports: 0,
|
||||
cursorPositionReportFragment: "",
|
||||
};
|
||||
|
||||
terminalProtocolReplyStates.set(term, {
|
||||
expiresAt: getNow() + TERMINAL_PROTOCOL_REPLY_WINDOW_MS,
|
||||
pendingCursorPositionReports: activeState.pendingCursorPositionReports + 1,
|
||||
cursorPositionReportFragment: activeState.cursorPositionReportFragment,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSuppressTerminalProtocolReplyBroadcast(term: object, data: string): boolean {
|
||||
const state = terminalProtocolReplyStates.get(term);
|
||||
if (!isStateActive(state)) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.pendingCursorPositionReports <= 0 || data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let remainingData = `${state.cursorPositionReportFragment}${data}`;
|
||||
let consumedCursorPositionReports = 0;
|
||||
|
||||
while (remainingData.length > 0) {
|
||||
const match = matchCursorPositionReportFromStart(remainingData);
|
||||
if (match.type === "none") {
|
||||
state.cursorPositionReportFragment = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match.type === "prefix") {
|
||||
if (consumedCursorPositionReports >= state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = remainingData;
|
||||
return true;
|
||||
}
|
||||
|
||||
consumedCursorPositionReports += 1;
|
||||
if (consumedCursorPositionReports > state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
remainingData = remainingData.slice(match.length);
|
||||
}
|
||||
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = "";
|
||||
if (state.pendingCursorPositionReports <= 0) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldBroadcastTerminalUserInput(
|
||||
term: object,
|
||||
data: string,
|
||||
options: BroadcastUserInputOptions,
|
||||
): boolean {
|
||||
const isSuppressedUserPaste = shouldSuppressTerminalBroadcastForUserPaste(term, data);
|
||||
return !isSuppressedUserPaste && !!options.isBroadcastEnabled && !!options.hasBroadcastInputHandler;
|
||||
const isSuppressedTerminalProtocolReply = shouldSuppressTerminalProtocolReplyBroadcast(term, data);
|
||||
return (
|
||||
!isSuppressedUserPaste &&
|
||||
!isSuppressedTerminalProtocolReply &&
|
||||
!!options.isBroadcastEnabled &&
|
||||
!!options.hasBroadcastInputHandler
|
||||
);
|
||||
}
|
||||
|
||||
function consumePasteInputState(
|
||||
|
||||
61
components/terminal/sftpCwd.test.ts
Normal file
61
components/terminal/sftpCwd.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
resolvePreferredTerminalCwd,
|
||||
} from "./sftpCwd";
|
||||
|
||||
test("resolvePreferredTerminalCwd returns the renderer cwd without probing the backend", async () => {
|
||||
let backendCalls = 0;
|
||||
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/srv/app/current",
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => {
|
||||
backendCalls += 1;
|
||||
return { success: true, cwd: "/root" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/srv/app/current");
|
||||
assert.equal(backendCalls, 0);
|
||||
});
|
||||
|
||||
test("resolvePreferredTerminalCwd falls back to backend pwd when no renderer cwd is known", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: undefined,
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async (sessionId) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
return { success: true, cwd: "/home/alice" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/home/alice");
|
||||
});
|
||||
|
||||
test("resolvePreferredTerminalCwd returns null when neither source has a cwd", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "",
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
assert.equal(cwd, null);
|
||||
});
|
||||
|
||||
test("terminal cwd tracker clears stale renderer cwd before falling back to backend pwd", async () => {
|
||||
const tracker = createTerminalCwdTracker();
|
||||
|
||||
tracker.setRendererCwd("/srv/old-session");
|
||||
tracker.clearRendererCwd();
|
||||
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: tracker.getRendererCwd(),
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => ({ success: true, cwd: "/home/fresh-session" }),
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/home/fresh-session");
|
||||
});
|
||||
53
components/terminal/sftpCwd.ts
Normal file
53
components/terminal/sftpCwd.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
type SessionPwdResult = {
|
||||
success: boolean;
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
type ResolvePreferredTerminalCwdOptions = {
|
||||
rendererCwd?: string | null;
|
||||
sessionId?: string | null;
|
||||
getSessionPwd: (sessionId: string) => Promise<SessionPwdResult>;
|
||||
};
|
||||
|
||||
const normalizeCwd = (cwd?: string | null): string | null => {
|
||||
if (typeof cwd !== "string" || cwd.trim().length === 0) return null;
|
||||
return cwd;
|
||||
};
|
||||
|
||||
export type TerminalCwdTracker = {
|
||||
getRendererCwd: () => string | undefined;
|
||||
setRendererCwd: (cwd?: string | null) => string | undefined;
|
||||
clearRendererCwd: () => void;
|
||||
};
|
||||
|
||||
export const createTerminalCwdTracker = (): TerminalCwdTracker => {
|
||||
let rendererCwd: string | undefined;
|
||||
|
||||
return {
|
||||
getRendererCwd: () => rendererCwd,
|
||||
setRendererCwd: (cwd) => {
|
||||
rendererCwd = normalizeCwd(cwd) ?? undefined;
|
||||
return rendererCwd;
|
||||
},
|
||||
clearRendererCwd: () => {
|
||||
rendererCwd = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePreferredTerminalCwd = async ({
|
||||
rendererCwd,
|
||||
sessionId,
|
||||
getSessionPwd,
|
||||
}: ResolvePreferredTerminalCwdOptions): Promise<string | null> => {
|
||||
const knownCwd = normalizeCwd(rendererCwd);
|
||||
if (knownCwd) return knownCwd;
|
||||
if (!sessionId) return null;
|
||||
|
||||
try {
|
||||
const result = await getSessionPwd(sessionId);
|
||||
return result.success ? normalizeCwd(result.cwd) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -34,6 +34,8 @@ export const terminalLayerAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
|
||||
68
domain/connectionLog.test.ts
Normal file
68
domain/connectionLog.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { ConnectionLog } from "./models.ts";
|
||||
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-base",
|
||||
sessionId: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Example",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
protocol: "ssh",
|
||||
startTime: 1000,
|
||||
localUsername: "local",
|
||||
localHostname: "machine",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
|
||||
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
|
||||
const staleLog = {
|
||||
...baseLog,
|
||||
id: "stale",
|
||||
sessionId: "session-2",
|
||||
startTime: 3000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
selectConnectionLogForTerminalDataCapture(
|
||||
[staleLog, matchingLog],
|
||||
{ sessionId: "session-1", hostname: "example.com" },
|
||||
)?.id,
|
||||
"active",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectConnectionLogForTerminalDataCapture reuses the latest log for repeated captures after reconnect", () => {
|
||||
const firstCapture = {
|
||||
...baseLog,
|
||||
id: "first-capture",
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
terminalData: "first disconnect",
|
||||
};
|
||||
const olderSameSession = {
|
||||
...baseLog,
|
||||
id: "older-same-session",
|
||||
startTime: 1500,
|
||||
endTime: 1800,
|
||||
terminalData: "older data",
|
||||
};
|
||||
const otherSession = {
|
||||
...baseLog,
|
||||
id: "other-session",
|
||||
sessionId: "session-2",
|
||||
startTime: 3000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
selectConnectionLogForTerminalDataCapture(
|
||||
[otherSession, olderSameSession, firstCapture],
|
||||
{ sessionId: "session-1", hostname: "example.com" },
|
||||
)?.id,
|
||||
"first-capture",
|
||||
);
|
||||
});
|
||||
25
domain/connectionLog.ts
Normal file
25
domain/connectionLog.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ConnectionLog } from "./models.ts";
|
||||
|
||||
interface TerminalDataCaptureTarget {
|
||||
sessionId: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export const selectConnectionLogForTerminalDataCapture = (
|
||||
connectionLogs: ConnectionLog[],
|
||||
target: TerminalDataCaptureTarget,
|
||||
): ConnectionLog | undefined => {
|
||||
const matchingOpenLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === target.sessionId;
|
||||
return !!target.hostname && log.hostname === target.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
|
||||
if (matchingOpenLog) return matchingOpenLog;
|
||||
|
||||
return connectionLogs
|
||||
.filter((log) => log.sessionId === target.sessionId)
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
};
|
||||
@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import {
|
||||
detectVendorFromSshVersion,
|
||||
normalizePrimaryTelnetState,
|
||||
resolveHostKeepalive,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
shouldProbeSessionCwd,
|
||||
upsertHostById,
|
||||
} from "./host.ts";
|
||||
|
||||
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
|
||||
assert.equal(detectVendorFromSshVersion("-"), "huawei");
|
||||
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
|
||||
// Reconnect / manual deviceType='network': host.distro already says network.
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
|
||||
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
|
||||
// isNetworkDevice is still false — the banner is the only signal (#1043).
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
test("resolveHostKeepalive falls back to global when override is not set", () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim();
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
* populated yet, so we also inspect the SSH server identification banner —
|
||||
* captured for free at handshake — which identifies most vendors directly.
|
||||
*/
|
||||
export const shouldProbeSessionCwd = (opts: {
|
||||
isNetworkDevice: boolean;
|
||||
remoteSshVersion?: string;
|
||||
}): boolean =>
|
||||
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
|
||||
@@ -720,7 +720,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
forcePromptNewLine: true, // Keep the next shell prompt visually separated from unterminated final output lines
|
||||
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
|
||||
import type { TerminalTheme } from "./models";
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
} from "./terminalAppearance";
|
||||
import type { Host, TerminalTheme } from "./models";
|
||||
|
||||
const baseTheme: TerminalTheme = {
|
||||
id: "ui-snow",
|
||||
@@ -46,3 +49,103 @@ test("keeps terminal theme unchanged without a valid custom accent", () => {
|
||||
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
|
||||
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
|
||||
});
|
||||
|
||||
const savedHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Core switch",
|
||||
hostname: "10.0.0.2",
|
||||
username: "admin",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
telnetEnabled: true,
|
||||
telnetPort: 23,
|
||||
};
|
||||
|
||||
test("terminal updates preserve saved connection protocol and port", () => {
|
||||
const telnetSessionHost: Host = {
|
||||
...savedHost,
|
||||
protocol: "telnet",
|
||||
port: 23,
|
||||
moshEnabled: false,
|
||||
fontFamily: "jetbrains-mono",
|
||||
fontFamilyOverride: true,
|
||||
};
|
||||
|
||||
const merged = mergeTerminalHostUpdate(savedHost, telnetSessionHost);
|
||||
|
||||
assert.equal(merged.protocol, "ssh");
|
||||
assert.equal(merged.port, 22);
|
||||
assert.equal(merged.moshEnabled, true);
|
||||
assert.equal(merged.telnetEnabled, true);
|
||||
assert.equal(merged.telnetPort, 23);
|
||||
assert.equal(merged.fontFamily, "jetbrains-mono");
|
||||
assert.equal(merged.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("terminal updates still persist credentials entered during connection", () => {
|
||||
const credentialUpdate: Host = {
|
||||
...savedHost,
|
||||
protocol: "telnet",
|
||||
port: 23,
|
||||
moshEnabled: false,
|
||||
username: "deploy",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
};
|
||||
|
||||
const merged = mergeTerminalHostUpdate(savedHost, credentialUpdate);
|
||||
|
||||
assert.equal(merged.protocol, "ssh");
|
||||
assert.equal(merged.port, 22);
|
||||
assert.equal(merged.moshEnabled, true);
|
||||
assert.equal(merged.username, "deploy");
|
||||
assert.equal(merged.authMethod, "password");
|
||||
assert.equal(merged.password, "secret");
|
||||
});
|
||||
|
||||
test("terminal updates still persist SFTP bookmarks", () => {
|
||||
const bookmarkUpdate: Host = {
|
||||
...savedHost,
|
||||
protocol: "telnet",
|
||||
port: 23,
|
||||
moshEnabled: false,
|
||||
sftpBookmarks: [{ id: "bookmark-1", path: "/srv/www", label: "/srv/www" }],
|
||||
};
|
||||
|
||||
const merged = mergeTerminalHostUpdate(savedHost, bookmarkUpdate);
|
||||
|
||||
assert.equal(merged.protocol, "ssh");
|
||||
assert.equal(merged.port, 22);
|
||||
assert.equal(merged.moshEnabled, true);
|
||||
assert.deepEqual(merged.sftpBookmarks, [
|
||||
{ id: "bookmark-1", path: "/srv/www", label: "/srv/www" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("terminal appearance reset clears only appearance fields", () => {
|
||||
const hostWithAppearance: Host = {
|
||||
...savedHost,
|
||||
fontSize: 16,
|
||||
fontSizeOverride: true,
|
||||
};
|
||||
const resetUpdate: Host = {
|
||||
...hostWithAppearance,
|
||||
protocol: "telnet",
|
||||
port: 23,
|
||||
moshEnabled: false,
|
||||
fontSize: undefined,
|
||||
fontSizeOverride: false,
|
||||
};
|
||||
|
||||
const merged = mergeTerminalHostUpdate(hostWithAppearance, resetUpdate);
|
||||
|
||||
assert.equal(merged.protocol, "ssh");
|
||||
assert.equal(merged.port, 22);
|
||||
assert.equal(merged.moshEnabled, true);
|
||||
assert.equal(merged.fontSize, undefined);
|
||||
assert.equal(merged.fontSizeOverride, false);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,25 @@ export const clearHostFontSizeOverride = (host: Host): Host => ({
|
||||
fontSizeOverride: false,
|
||||
});
|
||||
|
||||
export const mergeTerminalHostUpdate = (
|
||||
savedHost: Host,
|
||||
terminalHostUpdate: Host,
|
||||
): Host => {
|
||||
const nextHost: Host = {
|
||||
...terminalHostUpdate,
|
||||
id: savedHost.id,
|
||||
protocol: savedHost.protocol,
|
||||
port: savedHost.port,
|
||||
moshEnabled: savedHost.moshEnabled,
|
||||
};
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(savedHost, 'protocol')) delete nextHost.protocol;
|
||||
if (!Object.prototype.hasOwnProperty.call(savedHost, 'port')) delete nextHost.port;
|
||||
if (!Object.prototype.hasOwnProperty.call(savedHost, 'moshEnabled')) delete nextHost.moshEnabled;
|
||||
|
||||
return nextHost;
|
||||
};
|
||||
|
||||
export const resolveHostTerminalThemeId = (host: Host | null | undefined, defaultThemeId: string): string =>
|
||||
hasHostThemeOverride(host) && host?.theme ? host.theme : defaultThemeId;
|
||||
|
||||
|
||||
11
domain/terminalSettings.test.ts
Normal file
11
domain/terminalSettings.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeTerminalSettings } from "./models";
|
||||
|
||||
test("normalizeTerminalSettings disables prompt line breaks by default", () => {
|
||||
const settings = normalizeTerminalSettings();
|
||||
|
||||
assert.equal(settings.forcePromptNewLine, false);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
// Give the macOS build a unique Mach-O LC_UUID before signing, so macOS
|
||||
// Local Network privacy treats Netcatty distinctly from every other
|
||||
// Electron app (which all share Electron's prebuilt LC_UUID) — see #1040
|
||||
// and scripts/afterPackMacUuid.cjs. No-op on Windows/Linux.
|
||||
afterPack: './scripts/afterPackMacUuid.cjs',
|
||||
// Platform-split icons (#813):
|
||||
// - public/icon.png keeps Apple's HIG grid margin so the rendered
|
||||
// squircle sits at ~88% of the PNG canvas. macOS needs this —
|
||||
|
||||
@@ -552,6 +552,16 @@ function envPairsToObject(entries) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAgentEnv(env) {
|
||||
if (!env || typeof env !== "object" || Array.isArray(env)) return {};
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!key || value == null) continue;
|
||||
result[key] = String(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapMcpServerToCopilotConfig(server) {
|
||||
if (!server || typeof server !== "object" || !server.name) return null;
|
||||
|
||||
@@ -1481,15 +1491,8 @@ function registerHandlers(ipcMain) {
|
||||
return hasCodexAcpUsage && rejectedVersionFlag;
|
||||
}
|
||||
|
||||
function isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe) {
|
||||
return command === "claude" && usesAcpFallback && probe?.launched && probe.exitCode === 0;
|
||||
}
|
||||
|
||||
function isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
|
||||
return (
|
||||
isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) ||
|
||||
isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe)
|
||||
);
|
||||
return isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
|
||||
}
|
||||
|
||||
async function runCodexCli(args, options) {
|
||||
@@ -1691,7 +1694,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover external agents from PATH, plus bundled ACP binaries if present.
|
||||
// Discover external agents from PATH. Codex can additionally use a bundled ACP fallback.
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const agents = [];
|
||||
@@ -1704,7 +1707,6 @@ function registerHandlers(ipcMain) {
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
resolveAcp: resolveClaudeAcpBinaryPath,
|
||||
},
|
||||
{
|
||||
command: "codex",
|
||||
@@ -1732,12 +1734,11 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
for (const agent of knownAgents) {
|
||||
let resolvedPath = resolveCliFromPath(agent.command, shellEnv);
|
||||
const supportsBundledAcpFallback = agent.command === "codex";
|
||||
|
||||
// If the base command is not on PATH, check whether the bundled ACP
|
||||
// binary is available — the agent can still work via ACP without the
|
||||
// standalone CLI installed.
|
||||
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
|
||||
// resolveCodexAcpBinaryPath returns a plain string.
|
||||
// Codex can still work via bundled ACP if its standalone CLI is missing.
|
||||
// Claude must resolve to the system `claude` executable and pass version probing.
|
||||
// ACP resolvers return either a plain path or { command, prependArgs }.
|
||||
let versionCommand = null;
|
||||
let versionPrependArgs = [];
|
||||
let usesAcpFallback = false;
|
||||
@@ -1773,7 +1774,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!resolvedPath) {
|
||||
if (!resolvedPath && supportsBundledAcpFallback) {
|
||||
tryResolveAcpFallback();
|
||||
}
|
||||
|
||||
@@ -1793,7 +1794,7 @@ function registerHandlers(ipcMain) {
|
||||
probe,
|
||||
);
|
||||
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && agent.command === "codex") {
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && supportsBundledAcpFallback) {
|
||||
const previousPath = resolvedPath;
|
||||
if (tryResolveAcpFallback() && resolvedPath !== previousPath && !seenPaths.has(resolvedPath)) {
|
||||
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
|
||||
@@ -1844,18 +1845,6 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (command === "claude") {
|
||||
const acpPath = resolveClaudeAcpBinaryPath(shellEnv, electronModule);
|
||||
const scriptPath = acpPath?.prependArgs?.[0];
|
||||
const displayPath = scriptPath || acpPath?.command;
|
||||
if (displayPath && displayPath !== "claude-agent-acp" && existsSync(displayPath)) {
|
||||
return {
|
||||
displayPath,
|
||||
command: scriptPath ? acpPath.command : null,
|
||||
prependArgs: scriptPath ? acpPath.prependArgs : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const resolveBundledAcpFallback = () => {
|
||||
@@ -2121,7 +2110,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// Known agent command names (must match knownAgents in discover handler)
|
||||
const ALLOWED_AGENT_COMMANDS = new Set([
|
||||
"claude", "claude-agent-acp",
|
||||
"claude",
|
||||
"codex", "codex-acp",
|
||||
"copilot",
|
||||
]);
|
||||
@@ -2343,7 +2332,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── ACP (Agent Client Protocol) streaming ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId, agentEnv: requestedAgentEnv }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
@@ -2375,7 +2364,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
@@ -2456,7 +2445,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv: requestedAgentEnv }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
@@ -2684,7 +2673,7 @@ function registerHandlers(ipcMain) {
|
||||
);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
@@ -2808,7 +2797,9 @@ function registerHandlers(ipcMain) {
|
||||
: acpArgs || [],
|
||||
env: (() => {
|
||||
const fallbackEnv = withCliDiscoveryEnv(
|
||||
isCodexAgent && apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
isCodexAgent && apiKey
|
||||
? { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv), CODEX_API_KEY: apiKey }
|
||||
: { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) },
|
||||
);
|
||||
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
|
||||
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
|
||||
|
||||
@@ -65,6 +65,32 @@ function writeFakeCodexAcpLoaderError(filePath) {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function writeFakeBrokenClaudeCli(filePath) {
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"@echo off\r\necho file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95\r\nexit /b 1\r\n",
|
||||
"utf8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"#!/bin/sh\necho 'file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95'\nexit 1\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function writeFakeClaudeVersion(filePath, version = "2.1.145 (Claude Code)") {
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(filePath, `@echo off\r\necho ${version}\r\n`, "utf8");
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(filePath, `#!/bin/sh\necho '${version}'\n`, "utf8");
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function loadBridgeWithMocks(options = {}) {
|
||||
const streamCalls = [];
|
||||
const safeSendCalls = [];
|
||||
@@ -872,21 +898,12 @@ test("resolve-cli rejects bundled Codex ACP fallback when the fallback prints a
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers bundled Claude ACP fallback when the version probe is silent", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-discover-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const scriptPath = path.join(tempDir, "index.js");
|
||||
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
|
||||
|
||||
test("does not discover Claude without a system Claude CLI", async () => {
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [scriptPath],
|
||||
}),
|
||||
resolveClaudeAcpBinaryPath: () => {
|
||||
throw new Error("Claude ACP resolver should not be used for discovery");
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
@@ -903,17 +920,88 @@ test("discovers bundled Claude ACP fallback when the version probe is silent", a
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 1);
|
||||
assert.equal(agents[0].command, "claude");
|
||||
assert.equal(agents[0].path, scriptPath);
|
||||
assert.equal(agents[0].version, "Bundled ACP");
|
||||
assert.equal(agents[0].available, true);
|
||||
assert.equal(agents.length, 0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli accepts stored bundled Claude ACP script path via its launcher", async (t) => {
|
||||
test("does not discover Claude when the PATH Claude shim is broken", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-broken-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
|
||||
writeFakeBrokenClaudeCli(claudePath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||||
resolveClaudeAcpBinaryPath: () => {
|
||||
throw new Error("Claude ACP resolver should not be used for discovery");
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli detects PATH Claude and reads its version", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-resolve-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
|
||||
writeFakeClaudeVersion(claudePath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value || "").includes("Claude Code"),
|
||||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: claudePath,
|
||||
version: "2.1.145 (Claude Code)",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli rejects stored Claude adapter script paths", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-stored-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
@@ -925,10 +1013,9 @@ test("resolve-cli accepts stored bundled Claude ACP script path via its launcher
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
|
||||
normalizeCliPathForPlatform: () => scriptPath,
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [scriptPath],
|
||||
}),
|
||||
resolveClaudeAcpBinaryPath: () => {
|
||||
throw new Error("Claude ACP resolver should not be used for path resolution");
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
@@ -947,14 +1034,121 @@ test("resolve-cli accepts stored bundled Claude ACP script path via its launcher
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: scriptPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
version: null,
|
||||
available: false,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli rejects broken PATH Claude shims", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-resolve-broken-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
|
||||
writeFakeBrokenClaudeCli(claudePath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||||
resolveClaudeAcpBinaryPath: () => {
|
||||
throw new Error("Claude ACP resolver should not be used for path resolution");
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: claudePath,
|
||||
version: null,
|
||||
available: false,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP stream passes the configured system Claude executable to claude-agent-acp", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-executable-env-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const scriptPath = path.join(tempDir, "index.js");
|
||||
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
|
||||
|
||||
const { bridge, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [scriptPath],
|
||||
}),
|
||||
createACPProvider: () => ({
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {},
|
||||
getSessionId() {
|
||||
return "claude-session";
|
||||
},
|
||||
cleanup() {},
|
||||
}),
|
||||
streamText: () => createEmptyStreamResult(),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
await streamHandler({ sender: { id: 1 } }, {
|
||||
requestId: "req-claude-env",
|
||||
chatSessionId: "chat-claude-env",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
prompt: "hello",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: undefined,
|
||||
historyMessages: [],
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
agentEnv: { CLAUDE_CODE_EXECUTABLE: "/opt/homebrew/bin/claude" },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
providerCreationArgs[0].env.CLAUDE_CODE_EXECUTABLE,
|
||||
"/opt/homebrew/bin/claude",
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
83
electron/bridges/boringSslDhCompat.cjs
Normal file
83
electron/bridges/boringSslDhCompat.cjs
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* BoringSSL Diffie-Hellman group compatibility shim.
|
||||
*
|
||||
* Electron ships with BoringSSL, which no longer exposes some standard MODP
|
||||
* groups through the *named* `crypto.createDiffieHellmanGroup()` API — notably
|
||||
* the 1024-bit Oakley Group 2 ("modp2") that backs the SSH
|
||||
* `diffie-hellman-group1-sha1` key exchange. ssh2 calls
|
||||
* `createDiffieHellmanGroup('modp2')` for that kex, so on Electron it throws
|
||||
* "Unknown DH group" and legacy network devices that only speak group1-sha1
|
||||
* cannot be reached (issue #1035).
|
||||
*
|
||||
* The underlying DH math still works on BoringSSL via `createDiffieHellman()`
|
||||
* with an explicit prime, so this shim wraps `createDiffieHellmanGroup` to fall
|
||||
* back to the well-known prime constants when (and only when) the runtime can't
|
||||
* resolve a group by name. On OpenSSL builds the original call succeeds and the
|
||||
* fallback is never used, so behavior is unchanged there.
|
||||
*
|
||||
* IMPORTANT: ssh2 destructures `createDiffieHellmanGroup` at module load, so this
|
||||
* must be installed BEFORE ssh2 (or any bridge that requires it) is loaded.
|
||||
*/
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
// Standard MODP groups (RFC 2409 / RFC 3526), generator 2. These primes are
|
||||
// public constants and are byte-identical to Node's built-in groups, so the
|
||||
// fallback produces the exact same key exchange the named group would have.
|
||||
// Only groups that a runtime might drop yet ssh2 still requests need to live
|
||||
// here; modp14/16/18 remain available on BoringSSL so they are intentionally
|
||||
// omitted.
|
||||
const MODP_GROUP_PRIMES = {
|
||||
// Oakley Group 2 — RFC 2409, 1024-bit. ssh2: diffie-hellman-group1-sha1.
|
||||
modp2:
|
||||
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
||||
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
||||
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" +
|
||||
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
|
||||
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" +
|
||||
"FFFFFFFFFFFFFFFF",
|
||||
};
|
||||
const MODP_GENERATOR = Buffer.from([0x02]);
|
||||
|
||||
function createGroupFromPrime(name) {
|
||||
const primeHex = MODP_GROUP_PRIMES[name];
|
||||
if (!primeHex) return null;
|
||||
return crypto.createDiffieHellman(Buffer.from(primeHex, "hex"), MODP_GENERATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `target.createDiffieHellmanGroup` so missing named groups fall back to an
|
||||
* explicit-prime DiffieHellman. Idempotent. Returns true if it installed the
|
||||
* shim, false if it was already installed (or there was nothing to wrap).
|
||||
* @param {{ createDiffieHellmanGroup?: Function }} [target] defaults to the crypto module
|
||||
*/
|
||||
function installBoringSslDhCompat(target = crypto) {
|
||||
const original = target.createDiffieHellmanGroup;
|
||||
if (typeof original !== "function" || original.__boringSslDhCompat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wrapped = function createDiffieHellmanGroup(name) {
|
||||
try {
|
||||
return original(name);
|
||||
} catch (err) {
|
||||
const fallback = createGroupFromPrime(name);
|
||||
if (!fallback) throw err;
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
wrapped.__boringSslDhCompat = true;
|
||||
|
||||
try {
|
||||
target.createDiffieHellmanGroup = wrapped;
|
||||
} catch {
|
||||
// The property may be read-only on some runtimes; force it via defineProperty.
|
||||
Object.defineProperty(target, "createDiffieHellmanGroup", {
|
||||
value: wrapped,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { installBoringSslDhCompat, createGroupFromPrime, MODP_GROUP_PRIMES };
|
||||
76
electron/bridges/boringSslDhCompat.test.cjs
Normal file
76
electron/bridges/boringSslDhCompat.test.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const {
|
||||
installBoringSslDhCompat,
|
||||
MODP_GROUP_PRIMES,
|
||||
} = require("./boringSslDhCompat.cjs");
|
||||
|
||||
test("falls back to an explicit-prime DH when the runtime lacks a named group", () => {
|
||||
// Simulate BoringSSL: the named lookup throws "Unknown DH group".
|
||||
const target = {
|
||||
createDiffieHellmanGroup(name) {
|
||||
throw new Error(`Unknown DH group: ${name}`);
|
||||
},
|
||||
};
|
||||
assert.equal(installBoringSslDhCompat(target), true);
|
||||
|
||||
const dh = target.createDiffieHellmanGroup("modp2");
|
||||
// The fallback group uses the exact RFC 2409 group1 prime.
|
||||
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
|
||||
|
||||
// And it performs a real, correct DH exchange.
|
||||
const peer = crypto.createDiffieHellman(
|
||||
Buffer.from(MODP_GROUP_PRIMES.modp2, "hex"),
|
||||
Buffer.from([2]),
|
||||
);
|
||||
const ourPublic = dh.generateKeys();
|
||||
const peerPublic = peer.generateKeys();
|
||||
assert.ok(dh.computeSecret(peerPublic).equals(peer.computeSecret(ourPublic)));
|
||||
});
|
||||
|
||||
test("uses the runtime's group when the name resolves (no fallback)", () => {
|
||||
let calls = 0;
|
||||
const sentinel = Symbol("native-group");
|
||||
const target = {
|
||||
createDiffieHellmanGroup() {
|
||||
calls += 1;
|
||||
return sentinel;
|
||||
},
|
||||
};
|
||||
installBoringSslDhCompat(target);
|
||||
|
||||
assert.equal(target.createDiffieHellmanGroup("modp2"), sentinel);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("rethrows the original error for groups it cannot back", () => {
|
||||
const target = {
|
||||
createDiffieHellmanGroup() {
|
||||
throw new Error("Unknown DH group");
|
||||
},
|
||||
};
|
||||
installBoringSslDhCompat(target);
|
||||
|
||||
assert.throws(() => target.createDiffieHellmanGroup("modp-nonexistent"), /Unknown DH group/);
|
||||
});
|
||||
|
||||
test("install is idempotent", () => {
|
||||
const target = {
|
||||
createDiffieHellmanGroup() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
assert.equal(installBoringSslDhCompat(target), true);
|
||||
assert.equal(installBoringSslDhCompat(target), false);
|
||||
});
|
||||
|
||||
test("on this (OpenSSL) runtime the real modp2 still works through the shim", () => {
|
||||
// Sanity check against the actual crypto module: installing must not break the
|
||||
// normal path where the runtime resolves the group by name.
|
||||
const localCrypto = require("node:crypto");
|
||||
installBoringSslDhCompat(localCrypto);
|
||||
const dh = localCrypto.createDiffieHellmanGroup("modp2");
|
||||
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
|
||||
});
|
||||
@@ -98,6 +98,8 @@ const buildS3Client = (config) =>
|
||||
region: config.region,
|
||||
endpoint: normalizeEndpoint(config.endpoint),
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
@@ -299,4 +301,5 @@ module.exports = {
|
||||
// Exposed for tests
|
||||
handleWebdavInitialize,
|
||||
buildBasicAuthHeader,
|
||||
buildS3Client,
|
||||
};
|
||||
|
||||
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildS3Client,
|
||||
} = require("./cloudSyncBridge.cjs");
|
||||
|
||||
const config = {
|
||||
endpoint: "https://s3.example.com",
|
||||
region: "us-east-1",
|
||||
bucket: "netcatty-test",
|
||||
accessKeyId: "access",
|
||||
secretAccessKey: "secret",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
test("S3 client only sends request checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.requestChecksumCalculation(), "WHEN_REQUIRED");
|
||||
});
|
||||
|
||||
test("S3 client only validates response checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.responseChecksumValidation(), "WHEN_REQUIRED");
|
||||
});
|
||||
@@ -36,6 +36,10 @@ const {
|
||||
preparePrivateKeyForAuth,
|
||||
loadFirstIdentityFileForAuth,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const {
|
||||
buildSftpAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
} = require("./sshAlgorithms.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
@@ -481,44 +485,6 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration for SFTP connections.
|
||||
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
|
||||
*/
|
||||
function buildSftpAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
@@ -2471,6 +2437,8 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
getSftpClients,
|
||||
buildSftpAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
requireSftpChannel,
|
||||
encodePathForSession,
|
||||
ensureRemoteDirForSession,
|
||||
|
||||
157
electron/bridges/sshAlgorithms.cjs
Normal file
157
electron/bridges/sshAlgorithms.cjs
Normal file
@@ -0,0 +1,157 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const FIXED_DH_GROUP_BY_KEX = Object.freeze({
|
||||
"diffie-hellman-group1-sha1": "modp2",
|
||||
"diffie-hellman-group14-sha1": "modp14",
|
||||
"diffie-hellman-group14-sha256": "modp14",
|
||||
"diffie-hellman-group16-sha512": "modp16",
|
||||
"diffie-hellman-group18-sha512": "modp18",
|
||||
});
|
||||
|
||||
let _md5Supported = null;
|
||||
const dhGroupSupport = new Map();
|
||||
|
||||
// MODP groups that every SSH runtime we target supports, so we skip the
|
||||
// feature-detection probe for them. Under Electron's BoringSSL, instantiating a
|
||||
// fixed DH group object purely to test support is pathologically slow — the
|
||||
// 8192-bit modp18 alone takes ~20s on first call, freezing the first connection
|
||||
// of every app launch — yet the probe always succeeds. We only feature-detect
|
||||
// groups a runtime might genuinely drop (e.g. BoringSSL removed the weak
|
||||
// 1024-bit group1/modp2); those fail their probe instantly, so it stays cheap.
|
||||
const ASSUMED_SUPPORTED_DH_GROUPS = new Set(["modp14", "modp16", "modp18"]);
|
||||
|
||||
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
|
||||
// algorithm list can skip hmac-md5 on those builds; ssh2 validates exact
|
||||
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
|
||||
// before the SSH handshake even starts.
|
||||
function md5Supported() {
|
||||
if (_md5Supported === null) {
|
||||
try { _md5Supported = crypto.getHashes().includes("md5"); }
|
||||
catch { _md5Supported = false; }
|
||||
}
|
||||
return _md5Supported;
|
||||
}
|
||||
|
||||
function fixedDhGroupSupported(groupName) {
|
||||
if (ASSUMED_SUPPORTED_DH_GROUPS.has(groupName)) return true;
|
||||
if (!dhGroupSupport.has(groupName)) {
|
||||
try {
|
||||
crypto.createDiffieHellmanGroup(groupName);
|
||||
dhGroupSupport.set(groupName, true);
|
||||
} catch {
|
||||
dhGroupSupport.set(groupName, false);
|
||||
}
|
||||
}
|
||||
return dhGroupSupport.get(groupName);
|
||||
}
|
||||
|
||||
function filterSupportedFixedDhKex(kexAlgorithms) {
|
||||
return kexAlgorithms.filter((kexName) => {
|
||||
const groupName = FIXED_DH_GROUP_BY_KEX[kexName];
|
||||
return !groupName || fixedDhGroupSupported(groupName);
|
||||
});
|
||||
}
|
||||
|
||||
function buildBaseAlgorithms() {
|
||||
return {
|
||||
cipher: [
|
||||
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
|
||||
"aes128-ctr", "aes192-ctr", "aes256-ctr",
|
||||
],
|
||||
kex: filterSupportedFixedDhKex([
|
||||
"curve25519-sha256", "curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
]),
|
||||
compress: ["none"],
|
||||
};
|
||||
}
|
||||
|
||||
function applyLegacyAlgorithms(algorithms) {
|
||||
algorithms.kex.push(...filterSupportedFixedDhKex([
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
]));
|
||||
algorithms.cipher.push(
|
||||
"aes128-cbc", "aes256-cbc", "3des-cbc",
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
||||
"rsa-sha2-512", "rsa-sha2-256",
|
||||
"ssh-rsa", "ssh-dss",
|
||||
];
|
||||
}
|
||||
|
||||
function applyLegacyHmacAlgorithms(algorithms) {
|
||||
// Legacy HMACs: required by very old servers (e.g. FreeBSD 6.1 OpenSSH
|
||||
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
|
||||
// handshake exchange-hash MAC never agrees and the host-key signature
|
||||
// verification that depends on it fails with
|
||||
// "Handshake failed: signature verification failed", which looks like
|
||||
// a host-key problem but is really a MAC negotiation mismatch.
|
||||
//
|
||||
// hmac-md5 is only appended when the local OpenSSL build actually
|
||||
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
|
||||
// ssh2 strictly validates exact algorithm lists. Listing an unavailable
|
||||
// algorithm would throw "Unsupported algorithm" before any SSH
|
||||
// negotiation, turning the legacy toggle into a hard failure for FIPS
|
||||
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
|
||||
// stays unconditionally.
|
||||
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set. Keep it so
|
||||
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
|
||||
// C->S MAC" when legacy mode replaces the default list.
|
||||
algorithms.hmac = [
|
||||
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256", "hmac-sha2-512",
|
||||
"hmac-sha1-etm@openssh.com",
|
||||
"hmac-sha1",
|
||||
];
|
||||
if (md5Supported()) {
|
||||
algorithms.hmac.push("hmac-md5");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
* (lower priority than modern ones) for compatibility with older network equipment.
|
||||
*/
|
||||
function buildAlgorithms(legacyEnabled) {
|
||||
const algorithms = buildBaseAlgorithms();
|
||||
|
||||
if (legacyEnabled) {
|
||||
applyLegacyAlgorithms(algorithms);
|
||||
applyLegacyHmacAlgorithms(algorithms);
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration for SFTP connections.
|
||||
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
|
||||
*/
|
||||
function buildSftpAlgorithms(legacyEnabled) {
|
||||
const algorithms = buildBaseAlgorithms();
|
||||
|
||||
if (legacyEnabled) {
|
||||
applyLegacyAlgorithms(algorithms);
|
||||
applyLegacyHmacAlgorithms(algorithms);
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
function _resetAlgorithmSupportCacheForTests() {
|
||||
_md5Supported = null;
|
||||
dhGroupSupport.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAlgorithms,
|
||||
buildSftpAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
};
|
||||
216
electron/bridges/sshAlgorithms.test.cjs
Normal file
216
electron/bridges/sshAlgorithms.test.cjs
Normal file
@@ -0,0 +1,216 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { KexInit, HANDLERS: KEX_HANDLERS } = require("../../node_modules/ssh2/lib/protocol/kex.js");
|
||||
const { COMPAT, COMPAT_CHECKS, MESSAGE } = require("../../node_modules/ssh2/lib/protocol/constants.js");
|
||||
|
||||
const sshBridge = require("./sshBridge.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
|
||||
const BASE_FIXED_DH_KEX = [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group18-sha512",
|
||||
];
|
||||
|
||||
// Standard MODP groups we treat as supported without a runtime probe, because
|
||||
// probing them via createDiffieHellmanGroup is pathologically slow under
|
||||
// Electron's BoringSSL (~24s on first connection) yet always succeeds.
|
||||
const ASSUMED_SUPPORTED_GROUPS = ["modp14", "modp16", "modp18"];
|
||||
|
||||
function resetSupportCache() {
|
||||
sshBridge._resetAlgorithmSupportCacheForTests?.();
|
||||
sftpBridge._resetAlgorithmSupportCacheForTests?.();
|
||||
}
|
||||
|
||||
function withAlgorithmRuntime({ unsupportedGroups = new Set(), hashes = ["sha1", "sha256", "sha512", "md5"] }, callback) {
|
||||
const originalCreateGroup = crypto.createDiffieHellmanGroup;
|
||||
const originalGetHashes = crypto.getHashes;
|
||||
const probedGroups = [];
|
||||
|
||||
crypto.createDiffieHellmanGroup = (name) => {
|
||||
probedGroups.push(name);
|
||||
if (unsupportedGroups.has(name)) {
|
||||
throw new Error("Unknown DH group");
|
||||
}
|
||||
return {};
|
||||
};
|
||||
crypto.getHashes = () => hashes;
|
||||
|
||||
resetSupportCache();
|
||||
try {
|
||||
return callback({ probedGroups });
|
||||
} finally {
|
||||
crypto.createDiffieHellmanGroup = originalCreateGroup;
|
||||
crypto.getHashes = originalGetHashes;
|
||||
resetSupportCache();
|
||||
}
|
||||
}
|
||||
|
||||
function kexPayloadFrom(init) {
|
||||
const payload = Buffer.alloc(1 + 16 + init.totalSize + 1 + 4);
|
||||
payload[0] = MESSAGE.KEXINIT;
|
||||
init.copyAllTo(payload, 17);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildKexInit(algorithms) {
|
||||
return new KexInit({
|
||||
kex: algorithms.kex,
|
||||
serverHostKey: algorithms.serverHostKey,
|
||||
cs: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
sc: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readLegacyGexRequestBits(compatFlags) {
|
||||
const algorithms = sshBridge.buildAlgorithms(true);
|
||||
const writtenPackets = [];
|
||||
const protocol = {
|
||||
_server: false,
|
||||
_compatFlags: compatFlags,
|
||||
_offer: buildKexInit(algorithms),
|
||||
_debug: undefined,
|
||||
_strictMode: undefined,
|
||||
_kex: undefined,
|
||||
_kexinit: Buffer.from("local-kexinit"),
|
||||
_identRaw: Buffer.from("SSH-2.0-netcatty-test"),
|
||||
_remoteIdentRaw: Buffer.from("SSH-2.0-Comware-5.20"),
|
||||
_packetRW: {
|
||||
write: {
|
||||
allocStartKEX: 0,
|
||||
alloc(size) {
|
||||
return Buffer.alloc(size);
|
||||
},
|
||||
finalize(packet) {
|
||||
return packet;
|
||||
},
|
||||
},
|
||||
},
|
||||
_cipher: {
|
||||
encrypt(packet) {
|
||||
writtenPackets.push(Buffer.from(packet));
|
||||
},
|
||||
},
|
||||
};
|
||||
const remote = buildKexInit({
|
||||
kex: ["diffie-hellman-group-exchange-sha1"],
|
||||
serverHostKey: ["ecdsa-sha2-nistp256", "ssh-rsa"],
|
||||
cipher: ["aes128-ctr"],
|
||||
hmac: ["hmac-sha2-256"],
|
||||
compress: ["none"],
|
||||
});
|
||||
|
||||
KEX_HANDLERS[MESSAGE.KEXINIT](protocol, kexPayloadFrom(remote));
|
||||
|
||||
const request = writtenPackets.find((packet) => packet[0] === MESSAGE.KEXDH_GEX_REQUEST);
|
||||
assert.ok(request, "expected a DH group-exchange request packet");
|
||||
return {
|
||||
min: request.readUInt32BE(1),
|
||||
preferred: request.readUInt32BE(5),
|
||||
max: request.readUInt32BE(9),
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, buildAlgorithms] of [
|
||||
["SSH", sshBridge.buildAlgorithms],
|
||||
["SFTP", sftpBridge.buildSftpAlgorithms],
|
||||
]) {
|
||||
test(`${label} keeps standard DH groups without an expensive runtime probe`, () => {
|
||||
assert.equal(typeof buildAlgorithms, "function");
|
||||
|
||||
// Even when the runtime claims it can't create the standard MODP groups,
|
||||
// they must stay in the offer list AND must never be passed to
|
||||
// createDiffieHellmanGroup — probing them is what froze the first
|
||||
// connection of every app launch for ~24s under BoringSSL.
|
||||
withAlgorithmRuntime({ unsupportedGroups: new Set(ASSUMED_SUPPORTED_GROUPS) }, ({ probedGroups }) => {
|
||||
const modernAlgorithms = buildAlgorithms(false);
|
||||
const legacyAlgorithms = buildAlgorithms(true);
|
||||
|
||||
for (const kexName of BASE_FIXED_DH_KEX) {
|
||||
assert.ok(modernAlgorithms.kex.includes(kexName), `${kexName} should be offered`);
|
||||
assert.ok(legacyAlgorithms.kex.includes(kexName), `${kexName} should be offered (legacy)`);
|
||||
}
|
||||
assert.ok(legacyAlgorithms.kex.includes("diffie-hellman-group14-sha1"));
|
||||
|
||||
for (const group of ASSUMED_SUPPORTED_GROUPS) {
|
||||
assert.ok(!probedGroups.includes(group), `${group} must not be feature-probed`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} drops group1-sha1 when the runtime lacks modp2`, () => {
|
||||
withAlgorithmRuntime({ unsupportedGroups: new Set(["modp2"]) }, () => {
|
||||
const legacyAlgorithms = buildAlgorithms(true);
|
||||
|
||||
assert.equal(legacyAlgorithms.kex.includes("diffie-hellman-group1-sha1"), false);
|
||||
// Standard groups and the other legacy fallbacks must remain.
|
||||
assert.ok(legacyAlgorithms.kex.includes("diffie-hellman-group14-sha1"));
|
||||
for (const kexName of BASE_FIXED_DH_KEX) {
|
||||
assert.ok(legacyAlgorithms.kex.includes(kexName), `${kexName} should remain`);
|
||||
}
|
||||
assert.ok(legacyAlgorithms.kex.includes("diffie-hellman-group-exchange-sha1"));
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} legacy group-exchange SHA-1 is the last KEX fallback`, () => {
|
||||
withAlgorithmRuntime({}, () => {
|
||||
const legacyKex = buildAlgorithms(true).kex;
|
||||
const group14Sha1Index = legacyKex.indexOf("diffie-hellman-group14-sha1");
|
||||
const group1Sha1Index = legacyKex.indexOf("diffie-hellman-group1-sha1");
|
||||
const groupExchangeSha1Index = legacyKex.indexOf("diffie-hellman-group-exchange-sha1");
|
||||
|
||||
assert.notEqual(group14Sha1Index, -1);
|
||||
assert.notEqual(group1Sha1Index, -1);
|
||||
assert.notEqual(groupExchangeSha1Index, -1);
|
||||
assert.ok(group14Sha1Index < groupExchangeSha1Index);
|
||||
assert.ok(group1Sha1Index < groupExchangeSha1Index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("SFTP legacy HMAC algorithms match SSH legacy compatibility", () => {
|
||||
withAlgorithmRuntime({}, () => {
|
||||
const sshAlgorithms = sshBridge.buildAlgorithms(true);
|
||||
const sftpAlgorithms = sftpBridge.buildSftpAlgorithms(true);
|
||||
|
||||
assert.deepEqual(sftpAlgorithms.hmac, sshAlgorithms.hmac);
|
||||
assert.ok(sftpAlgorithms.hmac.includes("hmac-md5"));
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy HMAC algorithms skip MD5 when the runtime disables it", () => {
|
||||
withAlgorithmRuntime({ hashes: ["sha1", "sha256", "sha512"] }, () => {
|
||||
for (const algorithms of [
|
||||
sshBridge.buildAlgorithms(true),
|
||||
sftpBridge.buildSftpAlgorithms(true),
|
||||
]) {
|
||||
assert.ok(algorithms.hmac.includes("hmac-sha1"));
|
||||
assert.equal(algorithms.hmac.includes("hmac-md5"), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("Comware legacy group-exchange requests OpenSSH 6.4-sized DH groups", () => {
|
||||
const comwareCompatRule = COMPAT_CHECKS.find(([pattern, flags]) => (
|
||||
pattern instanceof RegExp
|
||||
&& pattern.test("Comware-5.20")
|
||||
&& (flags & COMPAT.COMWARE_DHGEX_1024)
|
||||
));
|
||||
|
||||
assert.ok(comwareCompatRule, "Comware servers should opt into the old DH group-exchange request size");
|
||||
assert.deepEqual(
|
||||
readLegacyGexRequestBits(COMPAT.COMWARE_DHGEX_1024),
|
||||
{ min: 1024, preferred: 1024, max: 8192 },
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,10 @@ const {
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const {
|
||||
buildAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
} = require("./sshAlgorithms.cjs");
|
||||
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -263,82 +267,28 @@ const log = (msg, data) => {
|
||||
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
|
||||
};
|
||||
|
||||
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
|
||||
// algorithm list can skip hmac-md5 on those builds — ssh2 validates exact
|
||||
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
|
||||
// before the SSH handshake even starts.
|
||||
let _md5Supported = null;
|
||||
function md5Supported() {
|
||||
if (_md5Supported === null) {
|
||||
try { _md5Supported = crypto.getHashes().includes("md5"); }
|
||||
catch { _md5Supported = false; }
|
||||
}
|
||||
return _md5Supported;
|
||||
function shouldLogSshDebugMessage(msg) {
|
||||
if (typeof msg !== "string") return false;
|
||||
return /auth|publickey|keyboard|handshake|kex|newkeys|dh gex/i.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
* (lower priority than modern ones) for compatibility with older network equipment.
|
||||
*/
|
||||
function buildAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
// Legacy HMACs — required by very old servers (e.g. FreeBSD 6.1 OpenSSH
|
||||
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
|
||||
// handshake exchange-hash MAC never agrees and the host-key signature
|
||||
// verification that depends on it fails with
|
||||
// "Handshake failed: signature verification failed" — which looks like
|
||||
// a host-key problem but is really a MAC negotiation mismatch.
|
||||
//
|
||||
// hmac-md5 is only appended when the local OpenSSL build actually
|
||||
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
|
||||
// ssh2 strictly validates exact algorithm lists — listing an unavailable
|
||||
// algorithm would throw "Unsupported algorithm" before any SSH
|
||||
// negotiation, turning the legacy toggle into a hard failure for FIPS
|
||||
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
|
||||
// stays unconditionally.
|
||||
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set — keep it so
|
||||
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
|
||||
// C->S MAC" when legacy mode replaces the default list.
|
||||
algorithms.hmac = [
|
||||
'hmac-sha2-256-etm@openssh.com', 'hmac-sha2-512-etm@openssh.com',
|
||||
'hmac-sha2-256', 'hmac-sha2-512',
|
||||
'hmac-sha1-etm@openssh.com',
|
||||
'hmac-sha1',
|
||||
];
|
||||
if (md5Supported()) {
|
||||
algorithms.hmac.push('hmac-md5');
|
||||
function attachSshDebugLogger(connectOpts) {
|
||||
if (!DEBUG_SSH) return;
|
||||
connectOpts.debug = (msg) => {
|
||||
if (shouldLogSshDebugMessage(msg)) {
|
||||
log("ssh2 debug", { msg });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
function logSshAlgorithms(label, algorithms, extra = {}) {
|
||||
log(`${label} algorithm configuration`, {
|
||||
...extra,
|
||||
kex: algorithms.kex,
|
||||
cipher: algorithms.cipher,
|
||||
hmac: algorithms.hmac,
|
||||
serverHostKey: algorithms.serverHostKey,
|
||||
});
|
||||
}
|
||||
|
||||
// Session storage - shared reference passed from main
|
||||
@@ -476,6 +426,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
attachSshDebugLogger(connOpts);
|
||||
logSshAlgorithms("Jump host", connOpts.algorithms, {
|
||||
hostname: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
legacyAlgorithms: !!options.legacyAlgorithms,
|
||||
});
|
||||
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
const hasCertificate =
|
||||
@@ -752,6 +708,12 @@ async function startSSHSession(event, options) {
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
attachSshDebugLogger(connectOpts);
|
||||
logSshAlgorithms("Target host", connectOpts.algorithms, {
|
||||
hostname: options.hostname,
|
||||
port: options.port || 22,
|
||||
legacyAlgorithms: !!options.legacyAlgorithms,
|
||||
});
|
||||
|
||||
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
|
||||
sender,
|
||||
@@ -1612,14 +1574,6 @@ async function startSSHSession(event, options) {
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
// Enable debug logging for ssh2 to diagnose auth issues
|
||||
connectOpts.debug = (msg) => {
|
||||
// Only log auth-related messages to avoid noise
|
||||
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
|
||||
log("ssh2 debug", { msg });
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -2712,4 +2666,6 @@ module.exports = {
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
buildAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
_shouldLogSshDebugMessage: shouldLogSshDebugMessage,
|
||||
};
|
||||
|
||||
42
electron/bridges/sshBridgeDebug.test.cjs
Normal file
42
electron/bridges/sshBridgeDebug.test.cjs
Normal file
@@ -0,0 +1,42 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { _shouldLogSshDebugMessage } = require("./sshBridge.cjs");
|
||||
|
||||
test("SSH debug logging keeps handshake and key exchange messages", () => {
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Handshake: KEX algorithm: diffie-hellman-group-exchange-sha1"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Handshake: (remote) KEX method: diffie-hellman-group14-sha1"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Outbound: Sending KEXDH_GEX_REQUEST"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Received DH GEX Group"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Outbound: Sending NEWKEYS"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("SSH debug logging keeps auth messages but drops noisy channel data", () => {
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Outbound: Sending USERAUTH_REQUEST (publickey -- check)"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Inbound: Received CHANNEL_DATA"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
_shouldLogSshDebugMessage("Outbound: Sending CHANNEL_WINDOW_ADJUST"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -93,6 +93,12 @@ function createLazyModule(modulePath) {
|
||||
};
|
||||
}
|
||||
|
||||
// Restore standard DH groups that Electron's BoringSSL dropped from the named
|
||||
// createDiffieHellmanGroup() API (e.g. modp2 / diffie-hellman-group1-sha1), so
|
||||
// legacy network devices stay reachable (#1035). MUST run before any module that
|
||||
// requires ssh2 — ssh2 destructures createDiffieHellmanGroup at load time.
|
||||
require("./bridges/boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||||
|
||||
// Import bridge modules
|
||||
const sshBridge = require("./bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("./bridges/sftpBridge.cjs");
|
||||
|
||||
@@ -1418,11 +1418,11 @@ const api = {
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
||||
},
|
||||
// ACP streaming
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext });
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv });
|
||||
},
|
||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId, agentEnv) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId, agentEnv });
|
||||
},
|
||||
aiAcpCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
||||
|
||||
4
global.d.ts
vendored
4
global.d.ts
vendored
@@ -1005,8 +1005,8 @@ declare global {
|
||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpListModels?(acpCommand: string, acpArgs?: string[], cwd?: string, providerId?: string, chatSessionId?: string): Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string, agentEnv?: Record<string, string>): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpListModels?(acpCommand: string, acpArgs?: string[], cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>): Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
|
||||
@@ -85,6 +85,41 @@ test('runAcpAgentTurn formats structured startup errors', async () => {
|
||||
assert.deepEqual(errors, ['Model is not available']);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn forwards configured ACP environment', async () => {
|
||||
let streamArgs: unknown[] = [];
|
||||
let done: (() => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async (...args: unknown[]) => {
|
||||
streamArgs = args;
|
||||
queueMicrotask(() => done?.());
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: (_requestId: unknown, cb: unknown) => {
|
||||
done = cb as () => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiAcpError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
'request-env',
|
||||
'chat-env',
|
||||
{
|
||||
...acpConfig,
|
||||
env: { CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude' },
|
||||
},
|
||||
'hello',
|
||||
createCallbacks([]),
|
||||
);
|
||||
|
||||
assert.deepEqual(streamArgs.at(-1), {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured async error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onError: ((error: unknown) => void) | null = null;
|
||||
|
||||
@@ -49,6 +49,7 @@ interface AcpBridge {
|
||||
toolIntegrationMode?: AIToolIntegrationMode,
|
||||
defaultTargetSession?: DefaultTargetSessionHint,
|
||||
userSkillsContext?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
): Promise<{ ok: boolean; error?: unknown }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
@@ -221,6 +222,7 @@ export async function runAcpAgentTurn(
|
||||
toolIntegrationMode,
|
||||
defaultTargetSession,
|
||||
userSkillsContext,
|
||||
config.env,
|
||||
).then((result) => {
|
||||
if (result?.ok === false) {
|
||||
settle(() => {
|
||||
|
||||
31
infrastructure/ai/managedAgents.test.ts
Normal file
31
infrastructure/ai/managedAgents.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { matchesManagedAgentConfig } from './managedAgents';
|
||||
|
||||
test('managed Claude matching ignores claude-agent-acp command-only configs', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig(
|
||||
{
|
||||
id: 'custom-claude-adapter',
|
||||
command: 'claude-agent-acp',
|
||||
acpCommand: 'custom-acp',
|
||||
},
|
||||
'claude',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('managed Claude matching ignores claude-agent-acp adapter configs', () => {
|
||||
assert.equal(
|
||||
matchesManagedAgentConfig(
|
||||
{
|
||||
id: 'custom-claude-adapter',
|
||||
command: 'claude-agent-acp',
|
||||
acpCommand: 'claude-agent-acp',
|
||||
},
|
||||
'claude',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
|
||||
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
|
||||
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
|
||||
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
|
||||
claude: { commandNames: ['claude'], acpCommand: 'claude-agent-acp' },
|
||||
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
|
||||
};
|
||||
|
||||
@@ -37,6 +37,13 @@ export function matchesManagedAgentConfig(
|
||||
): boolean {
|
||||
const meta = MANAGED_AGENT_META[agentKey];
|
||||
const basename = getCommandBasename(agent.command);
|
||||
if (agentKey === 'claude') {
|
||||
return (
|
||||
agent.id === 'discovered_claude' ||
|
||||
basename === 'claude' ||
|
||||
basename.startsWith('claude.')
|
||||
);
|
||||
}
|
||||
return (
|
||||
agent.id === `discovered_${agentKey}` ||
|
||||
agent.acpCommand === meta.acpCommand ||
|
||||
@@ -67,4 +74,3 @@ export function getManagedAgentStoredPath(
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -385,3 +385,109 @@ test('replays reasoning_content through the SDK tool loop', async (t) => {
|
||||
const messages = followUpBody.messages as Array<Record<string, unknown>>;
|
||||
assert.equal(messages[1].reasoning_content, 'need disk context');
|
||||
});
|
||||
|
||||
test('continues OpenAI-compatible tool streams when the introductory tool chunk omits id', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const emitChatChunk = (emit: (data: string) => void, delta: Record<string, unknown>, finishReason?: string) => {
|
||||
emit(JSON.stringify({
|
||||
id: 'chatcmpl-glm-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1777600000,
|
||||
model: 'glm-5.1',
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
|
||||
}));
|
||||
};
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (
|
||||
requestId: string,
|
||||
_url: string,
|
||||
_headers: Record<string, string>,
|
||||
body: string,
|
||||
) => {
|
||||
sentBodies.push(JSON.parse(body));
|
||||
const requestNumber = sentBodies.length;
|
||||
setTimeout(() => {
|
||||
const emit = dataHandlers.get(requestId);
|
||||
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
|
||||
if (requestNumber === 1) {
|
||||
emitChatChunk(emit, {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '' },
|
||||
}],
|
||||
});
|
||||
emitChatChunk(emit, {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: '{}' },
|
||||
}],
|
||||
});
|
||||
emitChatChunk(emit, {}, 'tool_calls');
|
||||
} else {
|
||||
emitChatChunk(emit, { content: 'tool completed' });
|
||||
emitChatChunk(emit, {}, 'stop');
|
||||
}
|
||||
endHandlers.get(requestId)?.();
|
||||
}, 0);
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const model = createModelFromConfig({
|
||||
id: 'glm-custom',
|
||||
providerId: 'custom',
|
||||
name: 'GLM',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://tokenhub.tencentmaas.com/plan/v3',
|
||||
defaultModel: 'glm-5.1',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'inspect the host' }],
|
||||
tools: {
|
||||
terminal_exec: tool({
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ ok: true }),
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(2),
|
||||
});
|
||||
|
||||
let text = '';
|
||||
for await (const chunk of result.fullStream) {
|
||||
if (chunk.type === 'text-delta') {
|
||||
text += chunk.text;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(text, 'tool completed');
|
||||
const followUpMessages = sentBodies[1].messages as Array<Record<string, unknown>>;
|
||||
const assistantMessage = followUpMessages[1] as { tool_calls?: Array<{ id?: string }> };
|
||||
const toolMessage = followUpMessages[2] as { tool_call_id?: string };
|
||||
assert.ok(assistantMessage.tool_calls?.[0]?.id?.startsWith('call_netcatty_'));
|
||||
assert.equal(toolMessage.tool_call_id, assistantMessage.tool_calls?.[0]?.id);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,87 @@ function createOpenAIChatStreamFieldCapture(
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) => string {
|
||||
const toolCallIdsByChoiceAndIndex = new Map<string, string>();
|
||||
const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
return (data: string): string => {
|
||||
if (!data || data.trim() === '[DONE]') return data;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record<string, unknown>).choices)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const normalizedChoices = ((parsed as Record<string, unknown>).choices as unknown[]).map((choice, choicePosition) => {
|
||||
if (!choice || typeof choice !== 'object') return choice;
|
||||
const choiceRecord = choice as Record<string, unknown>;
|
||||
const delta = choiceRecord.delta;
|
||||
if (!delta || typeof delta !== 'object') return choice;
|
||||
|
||||
const deltaRecord = delta as Record<string, unknown>;
|
||||
if (!Array.isArray(deltaRecord.tool_calls)) return choice;
|
||||
|
||||
const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition;
|
||||
let deltaChanged = false;
|
||||
const normalizedToolCalls = deltaRecord.tool_calls.map((toolCall, toolCallPosition) => {
|
||||
if (!toolCall || typeof toolCall !== 'object') return toolCall;
|
||||
const toolCallRecord = toolCall as Record<string, unknown>;
|
||||
const toolCallIndex = typeof toolCallRecord.index === 'number' ? toolCallRecord.index : toolCallPosition;
|
||||
const key = `${choiceIndex}:${toolCallIndex}`;
|
||||
const existingId = toolCallIdsByChoiceAndIndex.get(key);
|
||||
|
||||
if (typeof toolCallRecord.id === 'string' && toolCallRecord.id) {
|
||||
toolCallIdsByChoiceAndIndex.set(key, toolCallRecord.id);
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
if (existingId || !hasFunctionName(toolCallRecord)) {
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
const syntheticId = `call_netcatty_${requestIdToken}_${choiceIndex}_${toolCallIndex}`;
|
||||
toolCallIdsByChoiceAndIndex.set(key, syntheticId);
|
||||
changed = true;
|
||||
deltaChanged = true;
|
||||
return { ...toolCallRecord, id: syntheticId };
|
||||
});
|
||||
|
||||
if (!deltaChanged) return choice;
|
||||
return {
|
||||
...choiceRecord,
|
||||
delta: {
|
||||
...deltaRecord,
|
||||
tool_calls: normalizedToolCalls,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) return data;
|
||||
return JSON.stringify({
|
||||
...(parsed as Record<string, unknown>),
|
||||
choices: normalizedChoices,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function hasFunctionName(toolCall: Record<string, unknown>): boolean {
|
||||
const fn = toolCall.function;
|
||||
return Boolean(
|
||||
fn &&
|
||||
typeof fn === 'object' &&
|
||||
typeof (fn as Record<string, unknown>).name === 'string' &&
|
||||
(fn as Record<string, unknown>).name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers as a plain Record<string, string> from various header formats.
|
||||
*/
|
||||
@@ -208,6 +289,7 @@ export function createBridgeFetchForSDK(
|
||||
if (isStreamingRequest(resolvedInit)) {
|
||||
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
|
||||
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
|
||||
|
||||
// Set up IPC event listeners BEFORE starting the stream to avoid
|
||||
// missing early events.
|
||||
@@ -216,9 +298,10 @@ export function createBridgeFetchForSDK(
|
||||
let cleanedUp = false;
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
captureOpenAIChatFields(data);
|
||||
const normalizedData = normalizeOpenAIChatToolCalls(data);
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
streamController?.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
streamController?.enqueue(encoder.encode(`data: ${normalizedData}\n\n`));
|
||||
});
|
||||
const unsubEnd = bridge.onAiStreamEnd(requestId, () => {
|
||||
try { streamController?.close(); } catch { /* already closed */ }
|
||||
|
||||
@@ -30,6 +30,7 @@ export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_PROXY_PROFILES = 'netcatty_proxy_profiles_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_SORT_MODE = 'netcatty_vault_hosts_sort_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
|
||||
@@ -194,6 +194,8 @@ export class S3Adapter {
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
|
||||
66
lib/tabInteractions.test.ts
Normal file
66
lib/tabInteractions.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
handleTabMiddleClickClose,
|
||||
handleTabMiddleMouseDown,
|
||||
} from "./tabInteractions.ts";
|
||||
|
||||
interface FakeMouseEvent {
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
stopPropagation: () => void;
|
||||
}
|
||||
|
||||
const makeEvent = (button: number) => {
|
||||
const calls = { preventDefault: 0, stopPropagation: 0 };
|
||||
const event = {
|
||||
button,
|
||||
preventDefault: () => {
|
||||
calls.preventDefault++;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
calls.stopPropagation++;
|
||||
},
|
||||
} satisfies FakeMouseEvent;
|
||||
return { event: event as unknown as React.MouseEvent, calls };
|
||||
};
|
||||
|
||||
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(calls.preventDefault, 1);
|
||||
assert.equal(calls.stopPropagation, 1);
|
||||
});
|
||||
|
||||
test("handleTabMiddleClickClose ignores left and right clicks", () => {
|
||||
for (const button of [0, 2]) {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(button);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 0, `button ${button} must not close the tab`);
|
||||
assert.equal(calls.preventDefault, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
|
||||
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
handleTabMiddleMouseDown(middle.event);
|
||||
assert.equal(middle.calls.preventDefault, 1);
|
||||
|
||||
const left = makeEvent(0);
|
||||
handleTabMiddleMouseDown(left.event);
|
||||
assert.equal(left.calls.preventDefault, 0);
|
||||
});
|
||||
34
lib/tabInteractions.ts
Normal file
34
lib/tabInteractions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
|
||||
* 0 = left/primary, 1 = middle, 2 = right/secondary.
|
||||
*/
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
/**
|
||||
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
|
||||
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
|
||||
* default there stops the panning-cursor overlay from appearing when a user
|
||||
* middle-clicks a tab to close it (#1044).
|
||||
*/
|
||||
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
|
||||
if (e.button === MIDDLE_MOUSE_BUTTON) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
|
||||
* a completed non-primary click. Left clicks (tab activation) and right clicks
|
||||
* (context menu) are ignored so existing behavior is untouched.
|
||||
*/
|
||||
export const handleTabMiddleClickClose = (
|
||||
e: React.MouseEvent,
|
||||
close: () => void,
|
||||
): void => {
|
||||
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
@@ -738,3 +738,40 @@ index 9f33c02..9751164 100644
|
||||
}
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..4b3f71a 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -160,4 +160,5 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
+ COMWARE_DHGEX_1024: 1 << 5,
|
||||
};
|
||||
@@ -330,6 +331,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
+ [ /^Comware-/, COMPAT.COMWARE_DHGEX_1024 ],
|
||||
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..4b5f792 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1377,8 +1377,13 @@ const createKeyExchange = (() => {
|
||||
this._generator = null;
|
||||
this._minBits = GEX_MIN_BITS;
|
||||
this._prefBits = dhEstimate(this.negotiated);
|
||||
- if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE)
|
||||
+ if (hashName === 'sha1'
|
||||
+ && (this._protocol._compatFlags & COMPAT.COMWARE_DHGEX_1024)) {
|
||||
+ this._minBits = 1024;
|
||||
+ this._prefBits = 1024;
|
||||
+ } else if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE) {
|
||||
this._prefBits = Math.min(this._prefBits, 4096);
|
||||
+ }
|
||||
this._maxBits = GEX_MAX_BITS;
|
||||
}
|
||||
start() {
|
||||
|
||||
147
scripts/afterPackMacUuid.cjs
Normal file
147
scripts/afterPackMacUuid.cjs
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* electron-builder afterPack hook — give the macOS app a unique Mach-O LC_UUID.
|
||||
*
|
||||
* macOS keys the "Local Network" privacy permission on the main executable's
|
||||
* Mach-O LC_UUID (see Apple TN3179). Electron's prebuilt binary is linked with
|
||||
* LLD, which derives the UUID from a content hash, so EVERY app built from the
|
||||
* same Electron version ships the *same* LC_UUID — even with a different bundle
|
||||
* id. That collision makes the Local Network grant unreliable: macOS may apply
|
||||
* another Electron app's decision to ours, so a user who toggles the permission
|
||||
* on still gets `EHOSTUNREACH` when connecting to LAN/VMware host-only addresses
|
||||
* (issue #1040).
|
||||
*
|
||||
* This hook rewrites the LC_UUID of the packaged main executable to a value
|
||||
* derived deterministically from the appId — stable across builds (so users
|
||||
* don't have to re-grant on every update) but distinct from every other app.
|
||||
* It runs in `afterPack`, i.e. BEFORE electron-builder code-signs, so the
|
||||
* signature/notarization covers the patched binary.
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const LC_UUID = 0x1b;
|
||||
const MH_MAGIC_64 = 0xfeedfacf; // thin 64-bit, little-endian on disk
|
||||
const MH_CIGAM_64 = 0xcffaedfe; // thin 64-bit, byte-swapped
|
||||
const FAT_MAGIC = 0xcafebabe; // fat, big-endian
|
||||
const FAT_MAGIC_64 = 0xcafebabf;
|
||||
const MACH_HEADER_64_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Deterministic, app-specific 16-byte UUID. Stable across builds (so the
|
||||
* Local Network grant survives updates) yet unique per appId.
|
||||
* @param {string} appId
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function deriveUuid(appId) {
|
||||
const hash = crypto.createHash("sha1").update(`netcatty-local-network|${appId}`).digest();
|
||||
const uuid = Buffer.from(hash.subarray(0, 16));
|
||||
uuid[6] = (uuid[6] & 0x0f) | 0x50; // version 5
|
||||
uuid[8] = (uuid[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
return uuid;
|
||||
}
|
||||
|
||||
function formatUuid(buf) {
|
||||
const h = buf.toString("hex").toUpperCase();
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch every LC_UUID load command inside a single thin Mach-O slice.
|
||||
* @returns {string[]} the old UUIDs that were replaced (hex)
|
||||
*/
|
||||
function patchThinSlice(buf, sliceOffset, uuid) {
|
||||
const magic = buf.readUInt32LE(sliceOffset);
|
||||
if (magic !== MH_MAGIC_64 && magic !== MH_CIGAM_64) return [];
|
||||
const swapped = magic === MH_CIGAM_64;
|
||||
const readU32 = (o) => (swapped ? buf.readUInt32BE(o) : buf.readUInt32LE(o));
|
||||
|
||||
const ncmds = readU32(sliceOffset + 16);
|
||||
let off = sliceOffset + MACH_HEADER_64_SIZE;
|
||||
const replaced = [];
|
||||
for (let i = 0; i < ncmds; i += 1) {
|
||||
const cmd = readU32(off);
|
||||
const cmdsize = readU32(off + 4);
|
||||
if (cmdsize <= 0) break;
|
||||
if (cmd === LC_UUID) {
|
||||
replaced.push(buf.subarray(off + 8, off + 24).toString("hex"));
|
||||
uuid.copy(buf, off + 8); // uuid[16] follows cmd(4) + cmdsize(4)
|
||||
}
|
||||
off += cmdsize;
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite all LC_UUID load commands in a Mach-O buffer (thin or fat) in place.
|
||||
* @returns {{ patched: number, oldUuids: string[] }}
|
||||
*/
|
||||
function patchMachOBuffer(buf, uuid) {
|
||||
const magicBE = buf.readUInt32BE(0);
|
||||
const oldUuids = [];
|
||||
|
||||
if (magicBE === FAT_MAGIC || magicBE === FAT_MAGIC_64) {
|
||||
const is64 = magicBE === FAT_MAGIC_64;
|
||||
const archSize = is64 ? 32 : 20;
|
||||
const nfat = buf.readUInt32BE(4);
|
||||
for (let i = 0; i < nfat; i += 1) {
|
||||
const archOff = 8 + i * archSize;
|
||||
const sliceOffset = is64
|
||||
? Number(buf.readBigUInt64BE(archOff + 8))
|
||||
: buf.readUInt32BE(archOff + 8);
|
||||
oldUuids.push(...patchThinSlice(buf, sliceOffset, uuid));
|
||||
}
|
||||
} else {
|
||||
oldUuids.push(...patchThinSlice(buf, 0, uuid));
|
||||
}
|
||||
|
||||
return { patched: oldUuids.length, oldUuids };
|
||||
}
|
||||
|
||||
function patchMachOFile(file, uuid) {
|
||||
const buf = fs.readFileSync(file);
|
||||
const result = patchMachOBuffer(buf, uuid);
|
||||
if (result.patched > 0) fs.writeFileSync(file, buf);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @param {import('electron-builder').AfterPackContext} context */
|
||||
async function afterPack(context) {
|
||||
if (context.electronPlatformName !== "darwin") return;
|
||||
|
||||
const appId = context.packager.appInfo.id || "com.netcatty.app";
|
||||
const productFilename = context.packager.appInfo.productFilename;
|
||||
const exePath = path.join(
|
||||
context.appOutDir,
|
||||
`${productFilename}.app`,
|
||||
"Contents",
|
||||
"MacOS",
|
||||
productFilename,
|
||||
);
|
||||
|
||||
if (!fs.existsSync(exePath)) {
|
||||
throw new Error(`[afterPack] macOS executable not found: ${exePath}`);
|
||||
}
|
||||
|
||||
const uuid = deriveUuid(appId);
|
||||
const { patched, oldUuids } = patchMachOFile(exePath, uuid);
|
||||
|
||||
if (patched === 0) {
|
||||
throw new Error(
|
||||
`[afterPack] No LC_UUID load command found in ${exePath} — Local Network UUID fix did not apply`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[afterPack] Mach-O LC_UUID rewritten for Local Network privacy (#1040): ` +
|
||||
`${oldUuids.map((h) => formatUuid(Buffer.from(h, "hex"))).join(", ")} -> ${formatUuid(uuid)} ` +
|
||||
`(${patched} slice(s), appId=${appId})`,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = afterPack;
|
||||
module.exports.default = afterPack;
|
||||
module.exports.deriveUuid = deriveUuid;
|
||||
module.exports.formatUuid = formatUuid;
|
||||
module.exports.patchMachOBuffer = patchMachOBuffer;
|
||||
module.exports.patchMachOFile = patchMachOFile;
|
||||
117
scripts/afterPackMacUuid.test.cjs
Normal file
117
scripts/afterPackMacUuid.test.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
deriveUuid,
|
||||
patchMachOBuffer,
|
||||
} = require("./afterPackMacUuid.cjs");
|
||||
|
||||
const LC_UUID = 0x1b;
|
||||
const LC_OTHER = 0x19;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
|
||||
// Build a minimal thin little-endian 64-bit Mach-O with two load commands:
|
||||
// one dummy command and one LC_UUID carrying `uuidBytes`.
|
||||
function buildThinMachO(uuidBytes) {
|
||||
const header = Buffer.alloc(32);
|
||||
header.writeUInt32LE(MH_MAGIC_64, 0); // magic
|
||||
header.writeUInt32LE(0x0100000c, 4); // cputype arm64 (value irrelevant)
|
||||
header.writeUInt32LE(0, 8); // cpusubtype
|
||||
header.writeUInt32LE(2, 12); // filetype
|
||||
header.writeUInt32LE(2, 16); // ncmds
|
||||
header.writeUInt32LE(16 + 24, 20); // sizeofcmds
|
||||
header.writeUInt32LE(0, 24); // flags
|
||||
header.writeUInt32LE(0, 28); // reserved
|
||||
|
||||
const dummy = Buffer.alloc(16);
|
||||
dummy.writeUInt32LE(LC_OTHER, 0); // cmd
|
||||
dummy.writeUInt32LE(16, 4); // cmdsize
|
||||
dummy.fill(0xab, 8); // payload sentinel
|
||||
|
||||
const uuidCmd = Buffer.alloc(24);
|
||||
uuidCmd.writeUInt32LE(LC_UUID, 0); // cmd
|
||||
uuidCmd.writeUInt32LE(24, 4); // cmdsize
|
||||
uuidBytes.copy(uuidCmd, 8);
|
||||
|
||||
return Buffer.concat([header, dummy, uuidCmd]);
|
||||
}
|
||||
|
||||
// Wrap one or more thin slices in a big-endian 32-bit fat binary.
|
||||
function buildFatMachO(slices) {
|
||||
const headerSize = 8 + slices.length * 20;
|
||||
const header = Buffer.alloc(headerSize);
|
||||
header.writeUInt32BE(0xcafebabe, 0); // FAT_MAGIC
|
||||
header.writeUInt32BE(slices.length, 4);
|
||||
|
||||
let offset = headerSize;
|
||||
const offsets = [];
|
||||
for (let i = 0; i < slices.length; i += 1) {
|
||||
const archOff = 8 + i * 20;
|
||||
header.writeUInt32BE(0x0100000c, archOff); // cputype
|
||||
header.writeUInt32BE(0, archOff + 4); // cpusubtype
|
||||
header.writeUInt32BE(offset, archOff + 8); // offset
|
||||
header.writeUInt32BE(slices[i].length, archOff + 12); // size
|
||||
header.writeUInt32BE(0, archOff + 16); // align
|
||||
offsets.push(offset);
|
||||
offset += slices[i].length;
|
||||
}
|
||||
|
||||
return Buffer.concat([header, ...slices]);
|
||||
}
|
||||
|
||||
test("deriveUuid is deterministic and 16 bytes", () => {
|
||||
const a = deriveUuid("com.netcatty.app");
|
||||
const b = deriveUuid("com.netcatty.app");
|
||||
assert.equal(a.length, 16);
|
||||
assert.ok(a.equals(b));
|
||||
});
|
||||
|
||||
test("deriveUuid differs per appId and sets version/variant bits", () => {
|
||||
const a = deriveUuid("com.netcatty.app");
|
||||
const b = deriveUuid("com.example.other");
|
||||
assert.ok(!a.equals(b));
|
||||
assert.equal(a[6] & 0xf0, 0x50); // version 5
|
||||
assert.equal(a[8] & 0xc0, 0x80); // RFC 4122 variant
|
||||
});
|
||||
|
||||
test("patchMachOBuffer rewrites LC_UUID in a thin Mach-O and leaves the rest intact", () => {
|
||||
const original = Buffer.alloc(16, 0x11);
|
||||
const buf = buildThinMachO(original);
|
||||
const uuid = deriveUuid("com.netcatty.app");
|
||||
|
||||
const { patched, oldUuids } = patchMachOBuffer(buf, uuid);
|
||||
|
||||
assert.equal(patched, 1);
|
||||
assert.equal(oldUuids[0], original.toString("hex"));
|
||||
// LC_UUID payload is now our derived uuid (uuid command starts at byte 48).
|
||||
assert.ok(buf.subarray(48 + 8, 48 + 24).equals(uuid));
|
||||
// Header magic + the dummy command's payload are untouched.
|
||||
assert.equal(buf.readUInt32LE(0), MH_MAGIC_64);
|
||||
assert.equal(buf.readUInt32LE(32), LC_OTHER);
|
||||
assert.ok(buf.subarray(32 + 8, 32 + 16).equals(Buffer.alloc(8, 0xab)));
|
||||
});
|
||||
|
||||
test("patchMachOBuffer patches every slice of a fat binary", () => {
|
||||
const slice1 = buildThinMachO(Buffer.alloc(16, 0x22));
|
||||
const slice2 = buildThinMachO(Buffer.alloc(16, 0x33));
|
||||
const fat = buildFatMachO([slice1, slice2]);
|
||||
const uuid = deriveUuid("com.netcatty.app");
|
||||
|
||||
const { patched } = patchMachOBuffer(fat, uuid);
|
||||
|
||||
assert.equal(patched, 2);
|
||||
});
|
||||
|
||||
test("patchMachOBuffer reports zero when there is no LC_UUID", () => {
|
||||
// A thin Mach-O whose single command is not LC_UUID.
|
||||
const header = Buffer.alloc(32);
|
||||
header.writeUInt32LE(MH_MAGIC_64, 0);
|
||||
header.writeUInt32LE(1, 16); // ncmds
|
||||
const cmd = Buffer.alloc(16);
|
||||
cmd.writeUInt32LE(LC_OTHER, 0);
|
||||
cmd.writeUInt32LE(16, 4);
|
||||
const buf = Buffer.concat([header, cmd]);
|
||||
|
||||
const { patched } = patchMachOBuffer(buf, deriveUuid("com.netcatty.app"));
|
||||
assert.equal(patched, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user