Compare commits

..
100 changed files with 558 additions and 4858 deletions

No files matched your search

-2
View File
@@ -877,7 +877,6 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -1696,7 +1695,6 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -225,9 +225,3 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});
+1 -12
View File
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -202,7 +202,6 @@ export function AppSideEffects() {
addConnectionLog,
updateConnectionLog,
updateHostLastConnected,
updateHostDistro,
importDataFromString,
readPersistedHosts,
groupConfigs,
@@ -1752,14 +1751,6 @@ export function AppSideEffects() {
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
if (ephemeralHostIds.has(hostId)) {
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
return;
}
updateHostDistro(hostId, distro);
}, [ephemeralHostIds, updateHostDistro]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1895,7 +1886,6 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet,
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
@@ -1972,7 +1962,6 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet,
splitSessionWithCurrentShell,
handleEndSessionDrag,
+4 -1
View File
@@ -20,6 +20,7 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -36,6 +37,7 @@ export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
@@ -245,7 +247,7 @@ export function TerminalHost() {
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: handlers.updateTerminalHostDistro,
updateHostDistro: vaultActions?.updateHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -273,6 +275,7 @@ export function TerminalHost() {
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
-7
View File
@@ -497,13 +497,6 @@ export const enAiMessages: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuMentionNote': 'Mention Note',
'ai.chat.mentionNoteSearch': 'Search notes…',
'ai.chat.mentionNoteEmpty': 'No matching notes',
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
'ai.chat.untitledNote': 'Untitled note',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
+2 -7
View File
@@ -710,13 +710,8 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
'hostDetails.os.auto': 'Automatic',
'hostDetails.os.unknown': 'Other / unknown',
'hostDetails.os.selection': 'System selection',
'hostDetails.os.current': 'Current system',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.title': 'Operating System',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
-7
View File
@@ -497,13 +497,6 @@ export const esAiMessages: Messages = {
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuMentionNote': 'Mencionar nota',
'ai.chat.mentionNoteSearch': 'Buscar notas…',
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
'ai.chat.untitledNote': 'Nota sin título',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
+2 -7
View File
@@ -710,13 +710,8 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
'hostDetails.os.auto': 'Automático',
'hostDetails.os.unknown': 'Otro / desconocido',
'hostDetails.os.selection': 'Selección del sistema',
'hostDetails.os.current': 'Sistema actual',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.title': 'Sistema operativo',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
-7
View File
@@ -449,13 +449,6 @@ export const ruAiMessages: Messages = {
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuMentionNote': 'Упомянуть заметку',
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
'ai.chat.untitledNote': 'Заметка без названия',
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
+2 -7
View File
@@ -747,13 +747,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
'hostDetails.os.auto': 'Автоматически',
'hostDetails.os.unknown': 'Другая / неизвестно',
'hostDetails.os.selection': 'Выбор системы',
'hostDetails.os.current': 'Текущая система',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.title': 'Операционная система',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
-7
View File
@@ -497,13 +497,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuMentionNote': '提及笔记',
'ai.chat.mentionNoteSearch': '搜索笔记…',
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
'ai.chat.untitledNote': '未命名笔记',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
+2 -7
View File
@@ -133,13 +133,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
'hostDetails.os.auto': '自动识别',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '识别方式',
'hostDetails.os.current': '当前系统',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.title': '操作系统',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
-7
View File
@@ -496,13 +496,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuMentionNote': '提及筆記',
'ai.chat.mentionNoteSearch': '搜尋筆記…',
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
'ai.chat.untitledNote': '未命名筆記',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
+2 -7
View File
@@ -132,13 +132,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
'hostDetails.os.auto': '自動辨識',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '辨識方式',
'hostDetails.os.current': '目前系統',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.title': '作業系統',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
@@ -1,100 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { transferRuntime } from "./transferRuntime";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { releaseTransferPauseTree } from "./transferPauseLatch";
for (const closePanel of [false, true]) {
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let finishListing!: () => void;
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
let listingStarted!: () => void;
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
let listCalls = 0;
let maxCount = 0;
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
});
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => undefined,
startStreamTransfer: async (options: { transferId: string }) => {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
return {};
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let running: Promise<unknown> | undefined;
let rootId = "";
let reconnects = 0;
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
reconnects++;
return { success: false, error: "unexpected reconnect" };
});
function Probe() {
ops = useSftpTransfers({
ownerId: "direct-runtime-owner", getActivePane: () => null,
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => {
listCalls++; listingStarted(); await listingGate;
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(ops);
await act(async () => {
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
await started;
});
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
assert.ok(root); rootId = root.id;
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
await act(async () => { await ops!.pauseTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
await act(async () => { await ops!.resumeTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
assert.equal(reconnects, 0);
finishListing();
await act(async () => { assert.equal(await running, "completed"); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
assert.equal(listCalls, 1);
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
} finally {
releaseTransferPauseTree(rootId, []);
finishListing();
await act(async () => { await running; renderer?.unmount(); });
if (rootId) sftpTransferCenterStore.dismiss(rootId);
unsubscribe();
sftpTransferCenterStore.setDedicatedResumeHandler(null);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
@@ -500,23 +500,3 @@ for (const newerResume of [false, true]) {
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
registerTransferWalk(root.id);
const { host, getTasks } = createHost([root, child], () => ({
pauseTransfer: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}));
await softPauseTransfer(host, root.id);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(child.id), true);
// Completion during soft-drain compacts this row before the user resumes.
host.setTasks(getTasks().filter((task) => task.id !== child.id));
const resumed = await softResumeTransfer(host, root.id);
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
+2 -10
View File
@@ -840,9 +840,7 @@ export function useSftpDirectoryTransferOps({
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
return { ...row, transferredBytes: completed };
return { ...row, transferredBytes: row.transferredBytes + 1 };
});
});
return;
@@ -926,12 +924,6 @@ export function useSftpDirectoryTransferOps({
|| parentRow.status === "pausing"
|| isPauseLatched(rootTaskId)
);
// Background completion may have already compacted this child and
// advanced the checkpoint. Count retained + compacted completions
// instead of incrementing the same file again when invoke settles.
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
+ prev.filter((row) => row.parentTaskId === rootTaskId
&& (row.status === "completed" || row.id === fileId)).length;
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
@@ -942,7 +934,7 @@ export function useSftpDirectoryTransferOps({
}
return {
...t,
transferredBytes: completed,
transferredBytes: t.transferredBytes + 1,
speed: t.speed,
};
}
+1 -12
View File
@@ -8,9 +8,6 @@
const pausedIds = new Set<string>();
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
// A completed child's history row can disappear while its worker still waits
// for the folder to resume. Keep the paused tree independent of UI history.
const pausedChildren = new Map<string, Set<string>>();
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
let barrier = barriers.get(taskId);
@@ -48,18 +45,11 @@ export function releaseTransferPause(taskId: string): void {
}
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
pausedChildren.delete(rootTaskId);
releaseTransferPause(rootTaskId);
for (const id of children) releaseTransferPause(id);
for (const id of childIds) releaseTransferPause(id);
}
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
if (childIds.length > 0) {
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) children.add(id);
pausedChildren.set(rootTaskId, children);
}
latchTransferPause(rootTaskId);
for (const id of childIds) latchTransferPause(id);
}
@@ -87,7 +77,6 @@ export function resetTransferPauseLatchesForTests(): void {
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
+123 -101
View File
@@ -37,7 +37,10 @@ import {
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
import { bumpTransferControlEpoch } from "./transferControlEpoch";
import {
bumpTransferControlEpoch,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
import { transferRuntime } from "./transferRuntime";
import {
clearTransferCancelled,
@@ -45,6 +48,7 @@ import {
isTransferCancelledFlag,
markTransferCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./transferCancelLatch";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import type { TransferConnectionLease } from "./transferConnectionPool";
@@ -1805,109 +1809,127 @@ export const useSftpTransfers = ({
return "attention";
}
const patchDownload = (updates: Partial<TransferTask>) => {
transferRuntime.patchTask(task.id, updates);
const canonical = transferRuntime.getTask(task.id);
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
};
const executeDownload = async (): Promise<TransferStatus> => {
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
await waitUntilTransferResumed(task.id);
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
// Runtime owns lifecycle while the walk is registered. Publish the
// final root there before mirroring it to the panel.
const rows = sftpTransferCenterStore.getSnapshot().tasks;
const liveParent = transferRuntime.getTask(task.id) ?? task;
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
childFailureCount,
});
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
patchDownload({
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
speed: 0,
...(resolved.status === "cancelled" ? {} : {
totalBytes: finalTotal,
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
}),
});
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
speed: 0,
});
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
try {
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
};
let result: TransferStatus = "failed";
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
transferRuntime.runWalk(task.id, async () => {
result = await executeDownload();
}),
);
return result;
// Use childFailureCount (tracked outside React state) to determine
// final status reliably, regardless of render timing.
// Cancel must win: transferDirectory counts cancelled children as errors,
// but cancelTransfer already marked the parent cancelled — do not demote
// it to failed with "Some files failed to transfer".
// Re-read cancel inside the state update so a cancel that lands after
// transferDirectory returns cannot be overwritten by completed/failed.
let appliedStatus: TransferStatus = "completed";
setTransfers((prev) => {
const liveParent = prev.find((candidate) => candidate.id === task.id);
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
(t) => t.parentTaskId === task.id && t.status === "completed",
).length;
return prev.map((t) => {
if (t.id !== task.id) return t;
const parentCancelled = t.status === "cancelled"
|| cancelledTasksRef.current.has(task.id);
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled,
childFailureCount,
});
appliedStatus = resolved.status;
if (resolved.status === "cancelled") {
cancelledTasksRef.current.delete(task.id);
return {
...t,
status: "cancelled" as TransferStatus,
error: undefined,
endTime: Date.now(),
// Keep partial progress — do not look 100% complete when cancelled.
speed: 0,
};
}
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
const hasFailures = resolved.status === "failed";
return {
...t,
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
totalBytes: finalTotal,
transferredBytes: hasFailures ? completedCount : finalTotal,
speed: 0,
};
});
});
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? {
...t,
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
}
: t,
),
);
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
const childIds = sftpTransferCenterStore.getSnapshot().tasks
.filter((candidate) => candidate.parentTaskId === task.id)
.map((candidate) => candidate.id);
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
settleTransferControlEpochTree(task.id, relatedChildIds);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sftpSessionsRef, acquireTransferSession],
+104 -152
View File
@@ -109,8 +109,6 @@ export interface SftpTransferCenterStore {
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
upsertTasks(incoming: readonly TransferTask[]): void;
canControl(taskId: string): boolean;
subscribeResume(listener: Listener): () => void;
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -433,13 +431,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
const notifyResume = () => resumeListeners.forEach((listener) => listener());
const clearResumeRequest = (taskId: string) => {
if (resumeRequests.delete(taskId)) notifyResume();
};
const resumePreparationFailures = new Map<string, string>();
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
const dedicatedResumeWaiters = new Set<{
@@ -1734,140 +1725,114 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
controls.adopt && controls.canAdopt?.(task)
)));
},
subscribeResume(listener) {
resumeListeners.add(listener);
return () => { resumeListeners.delete(listener); };
},
isResuming(taskId) {
if (!resumeRequests.has(taskId)) return false;
const task = tasks.find((candidate) => candidate.id === taskId);
return !!task && !task.error
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
pause: (taskId) => invoke(taskId, "pause"),
async resume(taskId) {
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
const runResume = async () => {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
}
return startFresh();
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
try {
return await runResume();
} finally {
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
}
return startFresh();
},
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
},
cancel: (taskId) => invoke(taskId, "cancel"),
async retry(taskId) {
const ownerId = findOwner(taskId);
const task = tasks.find((candidate) => candidate.id === taskId);
@@ -2465,16 +2430,3 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
() => fallbackRef.current,
);
}
/** Shared control feedback for every transfer surface, including newly mounted rows. */
export function useSftpTransferResuming(taskId: string): boolean {
return useSyncExternalStore(
(listener) => {
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
return () => { unsubscribeTask(); unsubscribeResume(); };
},
() => sftpTransferCenterStore.isResuming(taskId),
() => false,
);
}
@@ -1,46 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previous) Object.defineProperty(globalThis, "window", previous);
else Reflect.deleteProperty(globalThis, "window");
});
let settle!: (value: { success: boolean; reason?: string }) => void;
let started!: () => void;
const entered = new Promise<void>((resolve) => { started = resolve; });
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
resumeTransfer: () => { started(); return gate; },
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
cancelTransfer: async () => ({ success: true }),
} } });
const store = createSftpTransferCenterStore();
store.publishOwner("feedback", [{
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
speed: 0, startTime: 1, isDirectory: false, resumable: true,
}]);
let notifications = 0;
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
t.after(unsubscribe);
const running = store.resume("feedback");
assert.equal(store.isResuming("feedback"), true);
assert.equal(store.getTask("feedback")?.status, "paused");
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
await entered;
const followup = outcome === "pause" ? store.pause("feedback")
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
await Promise.all([running, followup]);
assert.equal(store.isResuming("feedback"), false);
assert.ok(notifications >= 2);
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
});
}
@@ -1,79 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
assert.ok(hook);
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
let visible = true;
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
sidePanelLiveStore.update(own);
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a');
visible = false;
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => { sidePanelLiveStore.update(other); });
visible = true;
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
let panelTabId = 'workspace-a';
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
await act(async () => { sidePanelLiveStore.update(own); });
assert.equal(observed.focusedSessionId, 'pane-a');
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
panelTabId = 'new-owner';
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
@@ -7,7 +7,6 @@ import {
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
@@ -51,13 +50,3 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
);
});
@@ -1,8 +1,4 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
import {
formatVaultNoteReferences,
isVaultNoteAttachment,
} from "./vaultNoteAttachment";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
@@ -85,16 +81,6 @@ export function isTerminalSelectionAttachment(
return attachment.terminalSelection === true;
}
/**
* Attachments whose text is inlined into the prompt instead of being sent as
* file parts: terminal selections and Vault note mentions.
*/
export function isInlineTextAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
): boolean {
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
@@ -109,11 +95,7 @@ export function buildPromptWithTerminalSelectionAttachments(
})
.filter((block): block is string => block !== null);
const notes = attachments.filter(isVaultNoteAttachment);
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
const blocks = [...terminalBlocks, ...noteBlocks];
if (blocks.length === 0) return prompt;
if (!prompt.trim()) return blocks.join("").trimStart();
return `${prompt}${blocks.join("")}`;
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
}
@@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
test("mentions carry identity without copying even large note bodies", () => {
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
const attachment = createVaultNoteAttachment(note)!;
assert.equal(attachment.vaultNoteId, note.id);
assert.equal(attachment.base64Data, "");
assert.equal(attachment.dataUrl, "");
assert.ok(JSON.stringify(attachment).length < 400);
assert.ok(isInlineTextAttachment(attachment));
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
assert.match(prompt, /vault_notes_get/);
assert.match(prompt, /latest content/);
assert.doesNotMatch(prompt, /secret body/);
});
test("same titled notes keep distinct exact IDs including imported special characters", () => {
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
assert.equal(metadata.noteId, id);
assert.equal(metadata.title, "Same title");
}
});
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
});
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
assert.match(prompt, /systemctl status nginx/);
assert.match(prompt, /empty-note/);
assert.match(prompt, /^compare/);
});
test("reference limit reserves replay space and does not count ordinary files", () => {
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
assert.equal(vaultNoteReferencesFit(notes), false);
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
assert.ok(accepted.length > 0);
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
});
-38
View File
@@ -1,38 +0,0 @@
import type { VaultNote } from "../../domain/models";
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
/** A mention stores only identity; the agent reads current content through the Vault tool. */
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
if (!note.id.trim() || note.id.length > 200) return null;
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
return {
id: crypto.randomUUID(),
filename: title,
mediaType: "text/markdown",
base64Data: "",
dataUrl: "",
vaultNoteId: note.id,
vaultNoteTitle: title,
};
}
export function isVaultNoteAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
): boolean {
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
}
/** JSON preserves special characters and whitespace in exact note IDs. */
export function formatVaultNoteReferences(
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
): string {
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
}
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
const notes = attachments.filter(isVaultNoteAttachment);
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
}
-5
View File
@@ -88,8 +88,6 @@ interface AIChatPanelContentProps {
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
notes?: VaultNote[];
hosts?: Host[];
/** Mention Note: attach a Vault → Notes entry as inline context. */
onMentionNote?: (note: VaultNote) => void;
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
@@ -159,7 +157,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setGlobalPermissionMode,
notes = [],
hosts = [],
onMentionNote,
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSection,
@@ -364,8 +361,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={mentionHosts}
notes={notes}
onMentionNote={onMentionNote}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
quickMessages={quickMessages}
+10 -66
View File
@@ -9,11 +9,9 @@ import type {
AIPanelView,
AgentModelPreset,
AISessionScope,
UploadedFile,
DiscoveredAgent,
ExternalAgentConfig,
} from '../infrastructure/ai/types';
import type { VaultNote } from '../domain/models';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import {
filterAgentModelPresetsForCliVersion,
@@ -22,7 +20,6 @@ import {
resolveAgentModelSelection,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { toast } from './ui/toast';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
@@ -45,9 +42,8 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isInlineTextAttachment,
isTerminalSelectionAttachment,
} from '../application/state/terminalSelectionAttachment';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
@@ -601,42 +597,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
toast.warning(t('ai.chat.mentionNoteUnavailable'));
return false;
}
if (!vaultNoteReferencesFit(attachments)) {
toast.warning(t('ai.chat.mentionNoteTooMany'));
return false;
}
return true;
}, [canMentionNotes, t]);
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
const mentionNote = useCallback((note: VaultNote) => {
if (!canMentionNotes) return;
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
if (!upload) {
toast.error(t('ai.chat.mentionNoteInvalid', {
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
}));
return;
}
const existing = currentDraftRef.current?.attachments ?? [];
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateDraft(scopeKey, currentAgentId, (current) => ({
...current,
attachments: [
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
upload,
],
}));
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
useEffect(() => {
if (isVisible) return undefined;
flushDraftText();
@@ -1181,7 +1141,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
if (!validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
@@ -1190,27 +1149,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
const noteOnlyTitle = attachments.find(
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
)?.vaultNoteTitle ?? '';
const titleText = trimmed || noteOnlyTitle.trim();
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
flushDraftText();
@@ -1387,7 +1337,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, titleText);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
@@ -1410,7 +1360,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
titleText,
titleText: trimmed,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
@@ -1421,7 +1371,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
@@ -1518,7 +1467,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
if (!validateNoteMentions(draft.attachments)) return;
const trimmed = draft.text.trim();
const attachments = draft.attachments.map((file) => ({
base64Data: file.base64Data,
@@ -1526,17 +1474,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if (!trimmed && !hasInlineTextAttachments) return;
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if (!trimmed && !hasTerminalSelectionAttachments) return;
const userMessageId = generateId();
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
setSteerWarnings(current => {
const next = { ...current };
delete next[sessionId];
@@ -1565,8 +1511,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
} finally {
setSteeringSessionId(current => current === sessionId ? null : current);
}
}, [
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
const stopStreamingForSession = useCallback(async (sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
@@ -1762,7 +1707,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
files={files}
addFiles={addFiles}
removeFile={removeFile}
onMentionNote={canMentionNotes ? mentionNote : undefined}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("resume from another panel is visible while backend verification is pending", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
let settleResume!: (result: { success: boolean }) => void;
let resumeCalls = 0;
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
resumeTransfer: async () => { resumeCalls += 1; return gate; },
} });
store.publishOwner("resume-display-test", [{
id: "resume-display", fileName: "resume-display.bin",
sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
startTime: 1, isDirectory: false, resumable: true,
}]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
assert.ok(toggle);
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
let resumed!: Promise<void>;
await act(async () => { resumed = store.resume("resume-display"); });
try {
await flushEffects();
assert.equal(resumeCalls, 1);
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
assert.ok(row);
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
} finally {
await act(async () => { settleResume({ success: true }); await resumed; });
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
});
+18 -3
View File
@@ -23,7 +23,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -356,6 +355,7 @@ function TransferRow({
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
const isDirParent = isDirectoryParentTask(task);
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
const activeChildren = useMemo(
@@ -397,8 +397,22 @@ function TransferRow({
const storeResuming = task.reconnectRequired === true
&& ["pending", "queued", "transferring"].includes(task.status)
&& !task.error;
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const isResuming = storeResuming
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
// Clear optimistic click once the store has left the idle-resume surface or failed.
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === "transferring"
|| task.status === "completed"
|| task.status === "failed"
|| task.status === "cancelled"
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
// Orphaned tasks after app restart (interrupted / attention / paused without a
// live panel owner) must still expose resume/cancel from the global center.
@@ -453,6 +467,7 @@ function TransferRow({
}));
};
const resumeTask = () => {
setResumeClicked(true);
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
// Only force-open the panel when the row still needs a live owner/adoption
// (e.g. conflict) — not on every remote-to-remote resume click.
@@ -64,6 +64,4 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
+15 -49
View File
@@ -1,4 +1,3 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -351,59 +350,26 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</CollapsibleContent>
</Collapsible>
</div>
)}
</HostDetailsSection>
)}
@@ -11,6 +11,7 @@ import {
applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections,
removeSelectedHostCredential,
shouldForceAuthMethodReselect,
@@ -111,9 +112,26 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("basic connection settings do not require operating system selection", () => {
test("host operating system can be selected in the ordinary host editor", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.doesNotMatch(markup, /hostDetails\.os\./);
assert.deepEqual(
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
[
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
],
);
assert.match(markup, /hostDetails\.os\.title/);
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
"utf8",
);
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
});
test("host credentials expose automatic and password-only choices", () => {
@@ -23,6 +23,12 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"],
] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate",
@@ -125,6 +131,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
);
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
? form.os as Host["os"]
: "linux";
const selectedHostOsLabel = t(
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
);
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -140,6 +152,31 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
<HostDetailsSettingRow
label={t("hostDetails.os.title")}
hint={t("hostDetails.os.desc")}
>
<Select
value={resolvedHostOs}
onValueChange={(value) => update("os", value as Host["os"])}
>
<SelectTrigger
className="h-8 w-32 gap-2"
aria-label={t("hostDetails.os.title")}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedHostOsLabel}
</span>
</SelectTrigger>
<SelectContent>
{HOST_OS_CHOICES.map(([value, labelKey]) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
+2 -48
View File
@@ -1,4 +1,3 @@
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
@@ -253,8 +252,6 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -567,11 +564,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
const requireFreshConnectionOnReconnectRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
@@ -646,17 +638,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
// Keep the history tracker's viewport size current so cursor-row and
// cursor-column moves are clamped exactly like the terminal clamps them.
const captureTerm = termRef.current;
if (captureTerm) {
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
// The tracker's wrap decisions must measure cell widths the way this
// terminal's Unicode provider does, or the preview diverges from what
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
}
const readableCommandData = commandLogRewriterRef.current.append(data);
// The alternate-screen preview reads the raw display stream (before the
// replay sanitizer, which drops alternate-screen output on purpose) but
@@ -1100,15 +1081,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
const resolvedAutocompleteOs = host.protocol === "local"
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
: (host.os || "linux");
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol,
terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device',
});
@@ -2296,7 +2274,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2398,7 +2375,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
requireFreshConnectionOnReconnectRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
@@ -2891,12 +2867,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2994,14 +2964,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -3026,10 +2988,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
@@ -3669,10 +3627,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
// This start is a reconnect: force the bridge to dial a fresh connection
// instead of borrowing a parked/live transport, so the server performs a
// new login and picks up remote supplementary-group changes (#3293).
requireFreshConnectionOnReconnectRef.current = true;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
-18
View File
@@ -332,21 +332,3 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
assert.match(source, /ai\.chat\.menuMentionNote/);
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
});
test('renders vault note attachment chips with the note title', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /file\.vaultNoteId \? \(/);
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
});
+4 -166
View File
@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
resolveModelSelectionWithThinking,
resolveThinkingSelection,
@@ -54,8 +54,6 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import type { VaultNote } from '../../domain/models';
import { createVaultNoteSearchIndex } from '../../domain/notes';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -218,10 +216,6 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Available Vault → Notes entries for the Mention Note picker */
notes?: VaultNote[];
/** Callback when the user picks a note to attach as context */
onMentionNote?: (note: VaultNote) => void;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
@@ -277,8 +271,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
notes = [],
onMentionNote,
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
@@ -293,7 +285,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
parked = false,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const composerDisabled = disabled || isSteering;
const composerTextRef = useRef(value);
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
@@ -306,15 +298,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Search query for the Mention Note picker
const [noteQuery, setNoteQuery] = useState('');
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
@@ -323,7 +313,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showThinkingPicker = activeMenu === 'thinking';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showNoteMention = activeMenu === 'noteMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
@@ -333,7 +322,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
setInputPanelPos(null);
setSlashQuery('');
setSlashRange(null);
setNoteQuery('');
}, []);
useEffect(() => {
@@ -594,12 +582,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [readComposerText, commitComposerText, closeAllMenus]);
const handleSelectNoteMention = useCallback((note: VaultNote) => {
onMentionNote?.(note);
closeAllMenus();
}, [onMentionNote, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
@@ -763,58 +746,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
// Mention Note picker: filtered notes, highlight reset, search-input focus
const searchMentionNotes = useMemo(
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
[notes, showNoteMention],
);
const noteMentionItems = useMemo(
() => searchMentionNotes?.(noteQuery) ?? [],
[searchMentionNotes, noteQuery],
);
const noteMentionKey = useMemo(
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
[noteMentionItems],
);
const noteSearchInputRef = useRef<HTMLInputElement>(null);
const noteListId = React.useId();
useEffect(() => {
if (showNoteMention) setActiveMenuIndex(0);
}, [showNoteMention, noteMentionKey]);
useEffect(() => {
if (!showNoteMention || noteMentionItems.length === 0) return;
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
useEffect(() => {
if (!showNoteMention) return;
noteSearchInputRef.current?.focus();
}, [showNoteMention]);
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
if (note) handleSelectNoteMention(note);
return;
}
if (noteMentionItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
}
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
@@ -1110,8 +1041,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
>
{file.terminalSelection ? (
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
) : file.vaultNoteId ? (
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
) : file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
@@ -1124,12 +1053,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
</span>
</span>
) : file.vaultNoteId ? (
<span className="min-w-0">
<span className="block truncate max-w-[210px] text-foreground/82">
{file.vaultNoteTitle || file.filename}
</span>
</span>
) : (
<span className="truncate max-w-[80px]">{file.filename}</span>
)}
@@ -1286,76 +1209,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
document.body,
)}
{/* Mention Note popover */}
{showNoteMention && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="p-1.5 border-b border-border/40">
<input
ref={noteSearchInputRef}
role="combobox"
aria-label={t('ai.chat.menuMentionNote')}
aria-expanded={true}
aria-controls={noteListId}
aria-autocomplete="list"
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
type="text"
value={noteQuery}
onChange={(e) => setNoteQuery(e.target.value)}
onKeyDown={handleNoteMentionKeyDown}
placeholder={t('ai.chat.mentionNoteSearch')}
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
/>
</div>
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
{noteMentionItems.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
) : (
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
<VariableSizeVirtualList
ref={atMentionListRef}
items={noteMentionItems}
getItemHeight={(note) => note.group ? 52 : 36}
getItemKey={(note) => note.id}
className="h-full"
contentClassName="p-1"
renderItem={(note, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`${noteListId}-${idx}`}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectNoteMention(note)}
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
</div>
{note.group ? (
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
{note.group}
</div>
) : null}
</button>
);
}}
/>
</div>
)}
</div>
</div>
</>,
document.body,
)}
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
@@ -1458,19 +1311,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.menuMentionNote')}
disabled={!onMentionNote}
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
onClick={() => openInputPanelMenu('noteMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
>
<BookOpen size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
<button
type="button"
role="menuitem"
@@ -1800,8 +1640,6 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.onAddFiles === next.onAddFiles
&& prev.onRemoveFile === next.onRemoveFile
&& prev.hosts === next.hosts
&& prev.notes === next.notes
&& prev.onMentionNote === next.onMentionNote
&& prev.selectedUserSkills === next.selectedUserSkills
&& prev.userSkills === next.userSkills
&& prev.quickMessages === next.quickMessages
+1 -9
View File
@@ -6,7 +6,7 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
@@ -701,14 +701,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
</div>
) : att.vaultNoteId ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
</div>
) : att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
-66
View File
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
@@ -211,68 +210,3 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(5_000),
mediaType: "text/markdown",
filename: "runbook.md",
vaultNoteId: "note-123",
vaultNoteTitle: "Runbook",
previewText: "restart nginx",
};
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
assert.match(result, /check this note/);
assert.match(result, /Vault note reference/);
assert.match(result, /"noteId":"note-123"/);
assert.match(result, /"title":"Runbook"/);
assert.doesNotMatch(result, /AAAAAA/);
});
test("external recovery retains note identity before truncating a long user request", () => {
const history = buildExternalBridgeContextMessages([{
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
}]);
const replay = history.find((message) => message.role === "user")!;
assert.match(replay.content, /"noteId":"note-123"/);
assert.match(replay.content, /vault_notes_get/);
assert.match(replay.content, /truncated/);
assert.ok(replay.content.length <= 2000);
});
test("external recovery keeps multiple note IDs and a short user constraint", () => {
const attachments = Array.from({length: 10}, (_, index) => ({
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
}));
const history = buildExternalBridgeContextMessages([{
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
}]);
const replay = history.find((message) => message.role === "user")!;
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
assert.match(replay.content, /Only compare; do not edit/);
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
});
for (const terminalSelection of [false, true]) {
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
const noteId = '550e8400-e29b-41d4-a716-446655440000';
const request = "Only summarize yesterday's deployments; do not edit.";
const messages: ChatMessage[] = [{
id: 'old-note', role: 'user', timestamp: 1, content: request,
attachments: [
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
],
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
}))];
const compact = buildExternalBridgeContextMessages(messages)[0].content;
assert.ok(compact.includes(noteId));
assert.ok(compact.includes(request));
assert.ok(compact.includes('other.txt'));
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
});
}
+7 -12
View File
@@ -1,6 +1,5 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
@@ -51,19 +50,15 @@ export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
if (!attachments.length) return content;
// Keep note identities ahead of prose so bounded external history retains them.
const notes = attachments.filter(isVaultNoteAttachment);
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
@@ -1,96 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React, { useCallback, useEffect, useRef, useState } from "react";
import TestRenderer, { act } from "react-test-renderer";
import ts from "typescript";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Exercise the production lifecycle and asynchronous resolver together, without
// loading the unrelated provider forms or making real CLI/authentication calls.
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
...setters.map(name => `setIsResolving${name}`)];
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
const { ${bindings.join(",")} } = ctx;
${declarations}
return resolveAgentPath;
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
function deferred() {
let resolve!: (value: unknown) => void;
const promise = new Promise<unknown>(done => { resolve = done; });
return { promise, resolve };
}
async function mountResolver() {
const calls: ReturnType<typeof deferred>[] = [];
const applied: unknown[] = [];
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
function Harness() {
const [busy, setBusy] = useState(false);
state.busy = busy;
state.resolve = runHooks({
useRef, useEffect, useCallback,
getBridge: () => bridge,
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
cursorApiKeyEncrypted: "",
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
});
return null;
}
let root!: TestRenderer.ReactTestRenderer;
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
return { calls, applied, state, root };
}
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
const h = await mountResolver();
try {
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
assert.equal(h.state.busy, true);
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
await act(async () => { h.calls[0].resolve(result); await request; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
test("AI detection still ignores completion after real unmount", async () => {
const h = await mountResolver();
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
await act(async () => h.root.unmount());
h.calls[0].resolve({ installed: true });
await request;
assert.deepEqual(h.applied, []);
});
test("an older AI detection result cannot replace or finish the newer request", async () => {
const h = await mountResolver();
try {
let old!: Promise<unknown>;
let current!: Promise<unknown>;
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
assert.deepEqual(h.applied, []);
assert.equal(h.state.busy, true);
const result = { installed: true };
await act(async () => { h.calls[1].resolve(result); await current; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
+6 -10
View File
@@ -342,16 +342,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const mountedRef = useRef(true);
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
const codexRequestIdRef = useRef(0);
useEffect(() => {
mountedRef.current = true;
const agentPathRequestIds = agentPathRequestIdRef.current;
return () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
}
};
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
}, []);
const applyResolvedAgentPath = useCallback((
+20 -4
View File
@@ -20,10 +20,10 @@ import {
X,
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import {
@@ -136,6 +136,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// panel setTransfersState for every tick — that re-rendered the whole SFTP
// tree and pegged the renderer during large copies.
const task = useSftpTransferTask(propsTask.id, propsTask);
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
// Same progress model as the global transfer center (done · found for folders).
const isDirParent = isDirectoryParentTask(task);
@@ -157,8 +159,21 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const storeResuming = task.reconnectRequired === true
&& ['pending', 'queued', 'transferring'].includes(task.status)
&& !task.error;
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const isResuming = storeResuming
|| (resumeClicked && ['paused', 'interrupted', 'attention', 'pending', 'queued'].includes(task.status) && !task.error);
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === 'transferring'
|| task.status === 'completed'
|| task.status === 'failed'
|| task.status === 'cancelled'
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
@@ -402,6 +417,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className={actionButtonClass}
data-action="resume-transfer"
{...oncePerActivationHandlers(() => {
setResumeClicked(true);
onResume();
})}
aria-label={actionAriaLabel(resumeActionLabel)}
@@ -1,100 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
@@ -18,11 +18,10 @@ export function resolveTerminalAutocompleteSettings(input: {
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
const { protocol, terminalSettings, isNetworkDevice } = input;
if (protocol === "serial" || systemUnknown) {
if (protocol === "serial") {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
@@ -16,11 +16,6 @@ const armSudoPrompt = (
};
test("startMosh enables sudo autofill with the host saved password", async () => {
let detected = "";
let probes = 0;
let ready: (() => void) | undefined;
let resolveDetected!: () => void;
const detectionDone = new Promise<void>((resolve) => { resolveDetected = resolve; });
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
@@ -33,8 +28,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
onMoshSessionReady: (_id: string, callback: () => void) => { ready = callback; return noop; },
getSessionDistroInfo: async () => { probes++; return { success: true, stdout: "ID=ubuntu" }; },
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
@@ -56,7 +49,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
username: "alice",
password: "saved-secret",
},
onOsDetected: (_id: string, distro: string) => { detected = distro; resolveDetected(); },
keys: [],
identities: [],
resolvedChainHosts: [],
@@ -93,11 +85,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(probes, 0, "wait for the Mosh handshake before probing");
ready?.();
await detectionDone;
assert.equal(detected, "ubuntu");
assert.equal(probes, 1);
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
@@ -481,9 +481,9 @@ test("startSSH requests a fresh transport for ordinary opens with connection aut
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[2].reuseTransport, false);
assert.equal(captured[2].reuseTransport, undefined);
assert.equal(captured[2].sourceSessionId, undefined);
assert.equal(captured[3].reuseTransport, false);
assert.equal(captured[3].reuseTransport, undefined);
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
@@ -502,7 +502,9 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
resizeSession: noop,
};
// Ordinary opens and Duplicate Session both require a new login.
// Without the marker an ordinary open stays eligible for general endpoint
// reuse; with it, every attempt (including reconnects) dials fresh instead
// of borrowing the source's live authenticated transport.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
@@ -520,65 +522,12 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
await duplicateStarters.startSSH(createTermStub() as never);
await duplicateStarters.startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
});
test("startSSH dials a fresh transport once a pane has reconnected (#3293)", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
// An ordinary new tab must refresh groups even before its first reconnect.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: false },
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, false);
// After a reconnect (manual retry / auto-reconnect) every attempt must dial
// a brand-new connection so the server performs a fresh login and picks up
// remote supplementary-group changes (e.g. `usermod -aG`).
const reconnectedStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
terminalBackend,
}) as never);
await reconnectedStarters.startSSH(createTermStub() as never);
await reconnectedStarters.startSSH(createTermStub() as never);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
// Copy/Split may still have an unconsumed source while credentials load.
// A reconnect must discard that intent before the next backend attempt.
const pendingSource = { current: "source-session" as string | undefined };
const reuseAttempts: (string | undefined)[] = [];
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
reuseConnectionFromSessionIdRef: pendingSource,
setConnectionReuseAttemptSourceId: (id: string | undefined) => reuseAttempts.push(id),
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[3].reuseTransport, false);
assert.equal(captured[3].sourceSessionId, undefined);
assert.equal(pendingSource.current, undefined);
assert.deepEqual(reuseAttempts, [undefined]);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
@@ -656,7 +605,7 @@ test("startSSH rechecks the live automation policy before password fallback", as
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(commitCount, 0);
});
@@ -616,20 +616,22 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? ctx.host.identityFilePaths
: undefined;
let sourceReuseAttemptedWithinStart = false;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
useIdentityFiles?: boolean;
useSshAgent?: boolean;
}): Promise<string> => {
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
? undefined
: ctx.reuseConnectionFromSessionIdRef?.current;
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
?? sourceReuseAttemptedWithinStart;
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
if (ctx.reuseConnectionFromSessionIdRef) {
ctx.reuseConnectionFromSessionIdRef.current = undefined;
}
if (sourceSessionId) {
sourceReuseAttemptedWithinStart = true;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = true;
}
@@ -702,14 +704,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId,
// Only an explicit Copy/Split may share an existing login. Ordinary
// opens and reconnects must authenticate again to refresh remote groups.
reuseTransport: sourceSessionId ? undefined : false,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse. Duplicate
// Session clones must never borrow the source's live transport
// either, so they send an explicit `reuseTransport: false`.
reuseTransport: !sourceSessionId
&& (requiresFreshSshConnection || isFallbackAfterSourceReuse || ctx.requireFreshConnection === true)
? false
: undefined,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
});
if (!requiresFreshSshConnection) {
ctx.onConnectAutomationSnapshotCommitted?.();
}
sourceReuseAttemptedWithinStart = false;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = false;
}
@@ -1208,13 +1217,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
};
const detectMoshSystem = () => {
if (!isCurrentAttempt()) return;
const token = registerConnectionToken(attachedSessionId);
void runDistroDetection(ctx, attachedSessionId, token);
};
const runMoshStartup = () => {
detectMoshSystem();
disposeMoshReady?.();
disposeMoshReady = undefined;
ctx.setIsConnectionAwaitingUserInput?.(false);
@@ -1304,8 +1307,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
runMoshStartup();
}
} else {
// Older bridges without the ready event: the start call completed the handshake.
detectMoshSystem();
// Older bridges without the ready event: keep previous behavior.
scheduleStartupCommand(ctx, term, id);
}
} catch (err) {
@@ -154,13 +154,6 @@ export type TerminalSessionStartersContext = {
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
// the source's live or any other pooled transport.
requireFreshConnection?: boolean;
// Set by the reconnect path (manual retry / auto-reconnect) for the rest of
// the pane's lifetime: every SSH attempt must dial a brand-new connection
// instead of borrowing a live or idle pooled transport. Reusing an
// already-authenticated connection skips the server-side login, so remote
// supplementary-group changes (e.g. `usermod -aG`) stay invisible until the
// whole app quits (#3293).
requireFreshConnectionOnReconnectRef?: MutableRefObject<boolean>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
@@ -1,4 +1,3 @@
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
@@ -1066,43 +1065,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
// Native font advances differ from xterm's device-pixel-rounded cells.
// Fit each row to its terminal cell width, including double-width glyphs,
// rather than applying a single-cell correction once per Unicode glyph.
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
overlay.style.fontKerning = "none";
overlay.style.fontVariantLigatures = "none";
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
if (screenRect && screenRect.width > 0 && screenRect.height > 0
&& term.cols > 0 && term.rows > 0) {
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
const rowElements = previewRows.map((row) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.verticalAlign = "top";
span.style.transformOrigin = "left top";
span.textContent = row.text;
return span;
});
const fragment = document.createDocumentFragment();
rowElements.forEach((span, index) => {
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
fragment.appendChild(span);
});
overlay.replaceChildren(fragment);
// Batch all measurements before writing transforms to avoid one forced
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
rowElements.forEach((span, index) => {
const nativeWidth = nativeWidths[index];
const cells = stringCellWidth(previewRows[index].text, term);
if (nativeWidth > 0 && cells > 0) {
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
}
});
} else {
overlay.textContent = previewRows.map((row) => row.text).join("\n");
}
overlay.textContent = previewRows.map((row) => row.text).join("\n");
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
};
@@ -1699,13 +1662,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
// Space and uppercase letters can emit xterm data from keypress, after
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
// of sending the same character a second time as unpaired raw text.
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
}
if (e.type !== "keydown") {
return true;
}
@@ -2467,10 +2423,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
return;
}
// insertText can follow an already-delivered keypress. Its fallback marker
// must not classify the next physical key as a second composition commit.
// Actual composition/input handlers clear the physical-key pairing first.
if (broadcastLegacyDataPending) kittyCompositionPending = false;
if (kittyCompositionPending && !data.startsWith("\u001b")) {
kittyCompositionPending = false;
if (kittyCompositionClearTimer !== undefined) {
@@ -2781,17 +2733,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
},
);
term.onResize(({ cols, rows }) => {
// An idle alternate-screen application may emit nothing after resizing.
// Update history now, using xterm's cursor after any normal-buffer reflow.
const buffer = term.buffer.active;
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
ctx.terminalOutputHistory?.syncViewportAfterResize({
cols, rows,
cursorX: buffer.cursorX,
cursorY: buffer.cursorY,
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
isWrapped: cursorLine?.isWrapped ?? false,
});
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
// dimensions re-rasterize cleanly (issue #1049).
clearWebglTextureAtlas();
@@ -1,169 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
// Execute the actual registered key/data callbacks, without constructing the
// renderer and its WebGL/addon stack. In particular, retain their real timer
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
function section(start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
return source.slice(from, to);
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
const callbackCode = ts.transpileModule(`
${declarations}
let win32InputModePendingEvent = null;
let kittyCompositionPending = false;
let kittyCompositionClearTimer;
const handleTerminalInputData = (data) => {
const inputSource = "terminal";
${suppression}
if (!suppressTerminalBroadcast) raw(data);
};
const textarea = null;
${compositionMarker}
${textInputMarker}
${keyboardCallback}
${dataCallback}
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
function setup(flags = 0) {
const writes: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
let receive!: (data: string) => void;
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
const forwarded = new Map<string, { targetSessionIds: string[] }>();
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, flags);
const options = {
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
};
const normalized = (input: KittyKeyboardBroadcastInput) => {
const result = resolveKittyKeyboardBroadcastInput(input, options);
if (result) writes.push(result.data);
};
const context = {
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
window: {
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout(id: number) { timers.delete(id); },
},
term: {
modes: { win32InputMode: false },
onData(fn: typeof receive) { receive = fn; },
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
},
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
broadcastForwardedKeys: forwarded,
broadcastKittyInput: normalized,
sanitizeTerminalInput,
shouldSplitRawPasteInputForWire: () => false,
raw: (data: string) => writes.push(data),
};
runInNewContext(callbackCode, context);
return {
writes, receive: (data: string) => receive(data), controls: context.controls,
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
press(key: string, code: string) {
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
forwarded.set(code, { targetSessionIds: ["target"] });
context.controls.mark(code);
},
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
release(key: string, code: string) {
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
forwarded.delete(code);
context.controls.clear();
},
};
}
for (const flags of [0, 8]) {
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
const runtime = setup(flags);
runtime.press(key, code);
const firstWrite = runtime.writes.join("");
assert.ok(firstWrite);
runtime.flushTimers();
assert.equal(runtime.keypress(key, code), true);
runtime.receive(key);
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
});
}
}
test("repeated physical presses each reach the broadcast target once", () => {
const runtime = setup();
for (let i = 0; i < 3; i++) {
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
}
assert.equal(runtime.writes.join(""), "AAA");
});
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
const runtime = setup();
runtime.keypress("B", "KeyB");
runtime.receive("B");
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
runtime.release("A", "KeyA");
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "BApaste");
});
test("unmatched keypress cleanup still permits a later paste", () => {
const runtime = setup();
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.flushTimers();
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "Apaste");
});
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
const runtime = setup();
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
runtime.press(key, code);
runtime.keypress(key, code);
runtime.receive(key);
runtime.controls.input({ data: key, inputType: "insertText" });
runtime.release(key, code);
// Real trace: the next key can arrive before insertText's zero-delay timer.
}
assert.equal(runtime.writes.join(""), " ABC");
});
test("keyless insertText is still broadcast as actual text", () => {
const runtime = setup();
runtime.controls.input({ data: "中文", inputType: "insertText" });
runtime.receive("中文");
assert.equal(runtime.writes.join(""), "中文");
});
@@ -106,43 +106,3 @@ test("runDistroDetection normalizes FreeBSD uname output", async () => {
assert.deepEqual(detected, ["freebsd"]);
});
test("Windows OpenSSH is identified without sending POSIX probes", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: 'SSH-2.0-OpenSSH_for_Windows_9.5' }),
getSessionDistroInfo: async () => { throw new Error('must not probe Windows with POSIX commands'); },
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'windows', registerConnectionToken('windows'));
assert.deepEqual(detected, ['windows']);
});
test("failed or unrecognized probes do not invent an operating system", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'unknown', os: 'linux' },
terminalBackend: {
getSessionDistroInfo: async () => ({ success: true, stdout: '', stderr: 'Linux command not found' }),
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'unknown', registerConnectionToken('unknown'));
assert.deepEqual(detected, []);
});
test("a superseded Windows detection cannot update the newer connection", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => {
registerConnectionToken('reconnected');
return { success: true, remoteSshVersion: 'OpenSSH_for_Windows_9.5' };
},
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'reconnected', registerConnectionToken('reconnected'));
assert.deepEqual(detected, []);
});
@@ -63,10 +63,6 @@ export const runDistroDetection = async (
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
ctx.onOsDetected?.(ctx.host.id, 'windows');
return;
}
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
@@ -93,13 +89,12 @@ export const runDistroDetection = async (
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = (res.stdout || "").trim();
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const rawDistro = idMatch
? idMatch[1]
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
// An os-release ID confirms Linux even for distributions without a dedicated icon.
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
: (data.split(/\s+/)[0] || "").toLowerCase();
const distro = normalizeDistroId(rawDistro) || rawDistro;
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
@@ -17,30 +17,10 @@ import {
isHistoryPreviewDismissClick,
joinHistoryPreviewSelectionText,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride.ts";
test("select all includes every preview row and preserves soft-wrap copying", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
try {
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
overlay.textContent = "plain\ntext";
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
overlay.textContent = "";
assert.equal(selectHistoryPreviewAll(overlay), false);
} finally {
dom.window.close();
}
});
const wheel = (
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
) => ({
@@ -372,23 +352,3 @@ test("history preview right-click is recognized as an app-menu target", () => {
false,
);
});
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
const first = overlay.firstChild!.firstChild!;
const last = overlay.lastChild!.firstChild!;
const selection = {
rangeCount: 1, anchorNode: first, focusNode: last,
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
};
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
assert.equal(getHistoryPreviewSelectionText(overlay, {
...selection, anchorNode: overlay, focusNode: overlay,
anchorOffset: 0, focusOffset: overlay.childNodes.length,
}), "中文abcdef");
dom.window.close();
});
@@ -147,7 +147,6 @@ export type HistoryPreviewSelectionLike = {
};
export type HistoryPreviewNodeLike = {
ownerDocument?: Pick<Document, "createRange">;
contains(node: { nodeType?: number } | null): boolean;
firstChild?: { nodeType?: number } | null;
getAttribute?(name: string): string | null;
@@ -272,16 +271,6 @@ const resolveOverlayTextOffset = (
node: { nodeType?: number } | null,
offset: number,
): number | null => {
if (overlay.ownerDocument && node) {
try {
const prefix = overlay.ownerDocument.createRange();
prefix.selectNodeContents(overlay as unknown as Node);
prefix.setEnd(node as Node, offset);
return prefix.toString().length;
} catch {
return null;
}
}
if (node === textNode) return offset;
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
return null;
@@ -358,7 +347,9 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
const selection = overlay.ownerDocument.getSelection();
if (!selection) return false;
const range = overlay.ownerDocument.createRange();
range.selectNodeContents(overlay);
const textNode = overlay.firstChild;
if (textNode) range.selectNodeContents(textNode);
else range.selectNodeContents(overlay);
selection.removeAllRanges();
selection.addRange(range);
return !selection.isCollapsed;
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import { spawnSync } from "node:child_process";
import {
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
@@ -160,58 +159,6 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
);
});
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the viewport and "f" wraps; the transcript commits both
// lines, but the preview must keep the soft-wrapped join between them.
history.append("abcdef\n");
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Captured at five columns, the transcript holds two wrap segments; a
// ten-column preview must rejoin them into the single row xterm shows
// after its resize reflow.
history.append("abcdef\n");
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdef"],
);
// A narrower preview keeps splitting the rejoined run, flagging the
// continuation rows.
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
["abc", "def"],
);
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("CUB moves from the displayed last column while a wrap is deferred", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
// the last column, so CUB 1 targets it minus one and X overwrites "e".
history.append("abcde\x1b[DX");
assert.deepEqual([...history.getLines()], ["abcXe"]);
history.clear();
// CUF from the same deferred state still clamps to the last column.
history.append("abcde\x1b[CX");
assert.deepEqual([...history.getLines()], ["abcdX"]);
});
test("preview windows clamp to the retained rows and pad short output", () => {
const history = createTerminalOutputHistoryPreview();
history.append("one\ntwo\n");
@@ -237,32 +184,6 @@ test("preview rows keep wide characters intact at a column boundary", () => {
);
});
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
// the local fallback counts two, so without the injected provider the
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
const widthTerm = {
_core: {
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
},
} as never;
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.setWidthTerminal(widthTerm);
history.append("abcd🖥X");
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
const preview = createTerminalOutputHistoryPreview();
preview.setWidthTerminal(widthTerm);
preview.append("abcd🖥X\n");
assert.deepEqual(
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
["abcd🖥", "X"],
);
});
test("wheel steps walk the preview from the newest row upwards", () => {
const history = createTerminalOutputHistoryPreview();
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
@@ -312,571 +233,4 @@ test("default retention bounds the preview history", () => {
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 6, transcript);
assert.ok(transcript.includes("cc"), transcript);
});
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
}
});
test("same-row cursor-home redraw stays one progress line", () => {
const history = createTerminalOutputHistoryPreview();
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
assert.deepEqual(history.getLines(), ['progress 99']);
});
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
const history = createTerminalOutputHistoryPreview();
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
history.clear();
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
assert.deepEqual(history.getLines(), ['NEW']);
});
test("row controls retain requested and inherited terminal columns", () => {
const history = createTerminalOutputHistoryPreview();
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
history.clear();
history.append('\x9b2;3fAB\x1b[2;3HXY');
assert.deepEqual(history.getLines(), [' XY']);
});
test("positioned output respects wide-cell boundaries and retained size limits", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
assert.deepEqual(history.getLines(), [' 中X']);
history.clear();
history.append('\x1b[2;999999999Hend');
assert.ok(history.getLines().join('').length <= 128);
assert.ok(history.getLines().join('').endsWith('end'));
});
test("CHA and relative horizontal moves stay on the tracked row", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abcde\x1b[1GX');
assert.deepEqual(history.getLines(), ['Xbcde']);
history.clear();
history.append('ab\x1b[3CX');
assert.deepEqual(history.getLines(), ['ab X']);
history.clear();
history.append('abcde\x1b[2DX');
assert.deepEqual(history.getLines(), ['abcXe']);
});
test("NEL and IND advance the row; RI moves back up", () => {
const history = createTerminalOutputHistoryPreview();
history.append('foo\x1bEbar');
assert.deepEqual(history.getLines(), ['foo', 'bar']);
history.clear();
history.append('foo\x1bDbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
});
test("cursor placement alone does not append blank history rows", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
assert.deepEqual(history.getLines(), ['abc']);
history.append('X');
assert.deepEqual(history.getLines(), ['abc', ' X']);
});
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
for (const glyph of ['😀', '👩‍💻', '中']) {
const history = createTerminalOutputHistoryPreview();
history.append(`A${glyph}B\x1b[1;2HX`);
assert.deepEqual(history.getLines(), ['AX B']);
history.clear();
history.append(`A${glyph}B\x1b[1;3HX`);
assert.deepEqual(history.getLines(), ['A XB']);
}
const history = createTerminalOutputHistoryPreview();
history.append('AéB\x1b[1;2HX');
assert.deepEqual(history.getLines(), ['AXB']);
});
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
// and keeps the suffix at its columns.
const history = createTerminalOutputHistoryPreview();
history.append('中A\x1b[1G\x1b[1K');
assert.deepEqual(history.getLines(), [' A']);
history.clear();
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
history.append('A中B\x1b[1;3H\x1b[1K');
assert.deepEqual(history.getLines(), [' B']);
});
test("cursor-row moves clamp to the reported terminal viewport", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom-row address + a down move past the viewport: the terminal keeps
// the cursor on row 24, so the status redraw must not split history lines.
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
history.clear();
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
// overwrites the bottom row in place instead of adding a history line.
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
assert.deepEqual(history.getLines(), ["redraw"]);
history.clear();
// Without a reported viewport the legacy behavior applies (columns are
// retained across row moves).
const unclamped = createTerminalOutputHistoryPreview();
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
});
test("absolute cursor columns clamp to the reported viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
// CUP past the last column clamps to it on screen; without the clamp the
// preview would pad 998 spaces and fabricate wrapped rows.
history.append("\x1b[1;999HX");
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
});
test("line feeds on the bottom row stay clamped to it", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
assert.deepEqual(history.getLines(), ["status", "NEW"]);
});
test("shrinking the viewport clamps the tracked cursor row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hbottom");
history.setViewportRows(10);
// The terminal moved the cursor to the new bottom row, so a relative move
// from there must stay a same-row redraw instead of committing a line.
history.append("\x1b[1BX");
assert.deepEqual(history.getLines(), ["bottomX"]);
});
test("relative row moves stop at the scroll region's bottom margin", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("invalid DECSTBM ranges are ignored", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom not greater than top: xterm ignores the sequence, so the existing
// text must stay on its row and later text must not home the cursor.
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A top past the viewport clamps to it, leaving no valid region either.
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A bottom past the viewport clamps to it; the valid region still applies.
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("growing the viewport resets the scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;24r");
history.setViewportRows(40);
// xterm resets the scroll region on resize, so a relative move past row 24
// reaches row 25 instead of stopping at the stale bottom margin.
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
assert.deepEqual(history.getLines(), ["row24", " row25"]);
});
test("clear resets retained scroll margins between terminal boots", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20robsolete");
history.clear();
// The fresh xterm instance has a full-viewport region; the new session must
// not inherit the previous boot's bottom margin.
history.setViewportRows(24);
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
assert.deepEqual(history.getLines(), ["row20", " row21"]);
});
test("narrowing the viewport clamps the tracked cursor column", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;80H");
history.setViewportCols(10);
// xterm clamps the cursor to the new last column; the next printable span
// must be written there, not padded out to the old column 80.
history.append("X");
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
});
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
history.setViewportCols(5);
// xterm trims the cursor row to the new width on a narrowing resize
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
// re-wrapped), so X overwrites the last retained cell rather than leaving
// a phantom `fgh` tail plus an extra wrapped preview row.
history.append("X");
assert.deepEqual(history.getLines(), ["abcdX"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdX"],
);
});
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(6);
history.append("中文");
history.setViewportCols(3);
// The second glyph spans cells 2-3 past the new width; xterm discards it
// whole, so the open row keeps only the glyph that still fits and the next
// printable span lands on the new last column.
assert.deepEqual(history.getLines(), ["中"]);
history.append("X");
assert.deepEqual(history.getLines(), ["中X"]);
});
test("printable output past the viewport width wraps the tracked cursor", () => {
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
// instead of adding a separate line below a stale wrapped row.
const input = "abcdef\x1b[2;1HXY";
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
}
});
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\rXYZ");
assert.deepEqual(history.getLines(), ["XYZde"]);
});
test("wide glyphs wrap whole at the viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(4);
history.append("中文中文中文\n");
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
});
test("origin mode resolves absolute rows against the scroll region", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// In origin mode both addresses target row 20 (top margin + 15, then the
// bottom-margin clamp), so the redraw overwrites `status` in place instead
// of retaining it as a stale separate line.
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["NEWtus"]);
history.clear();
// clear() forgets origin mode: rows are absolute again.
history.setViewportRows(24);
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
assert.deepEqual(history.getLines(), ["absolute"]);
});
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
const script = `
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
const results = [];
for (const glyph of ["中", "👩‍💻", "界界"]) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
results.push(history.getLines());
}
console.log(JSON.stringify(results));
`;
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
encoding: "utf8",
timeout: 5000,
});
assert.equal(result.error, undefined, String(result.error));
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
});
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde");
history.setViewportRows(24);
history.setViewportCols(5);
history.append("f");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
});
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The base character fills the last column; its combining mark arrives in
// the next chunk. xterm attaches the mark to that cell without wrapping.
history.append("abcde");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
});
test("a standalone zero-width mark after a control starts the wrapped row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The SGR control xterm parses resets its preceding-join state, so the
// combining mark that follows is a standalone grapheme: it must not attach
// to the previous row's final cell; the pending wrap happens and the mark
// begins the next row (like xterm's `15-graphemes` runtime).
history.append("abcde\x1b[31m");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
// The same holds when the control rides inside one display chunk.
history.clear();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\x1b[31ḿZ\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
});
test("combined private-mode controls apply every parameter", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
// the control whole would leave autowrap on and wrap the overflow into a
// second row (xterm keeps `abcdef` on one row as `abcdf`).
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
assert.deepEqual(history.getLines(), ["abcdf"]);
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
// printable output wraps once more.
history.append("\x1b[?6;7h\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
// (25 is DECTCEM, untracked here), so dropping the control whole would
// resolve the later CUP row absolutely instead of against the top margin.
const combined = createTerminalOutputHistoryPreview();
combined.setViewportRows(24);
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
assert.deepEqual(combined.getLines(), ["OLDtus"]);
});
test("a grapheme longer than the per-row piece cap is not dropped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// One base character trailed by more combining marks than the piece cap
// holds: cutting at a grapheme boundary yields an empty slice for it, and
// returning early would silently drop this grapheme and everything after.
const mark = "́";
history.append("abcd" + "e" + mark.repeat(80) + "Z");
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
});
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
// next narrow character overwrites the last cell without padding it again.
history.append("\x1b[?7l\x1b[1;5H中Z");
assert.deepEqual(history.getLines(), [" Z"]);
});
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hsaved\x1b7");
history.setViewportRows(10);
history.append("\x1b8X");
// The saved row 24 no longer exists; the restore lands on the bottom row so
// the later bottom-row address writes `Y` on the same line as `X` instead of
// keeping an obsolete extra line (the saved column is preserved).
history.append("\x1b[10;1HY");
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
});
test("RIS resets the tracked modes, margins, and cursor", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
history.append("\x1b[?7l\x1bcabcdef");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
// RIS also clears the scroll region, DECOM, and the saved cursor.
const reset = createTerminalOutputHistoryPreview();
reset.setViewportRows(24);
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
// Without the reset, the CUP row would be relative to the top margin and
// clamp to its bottom (row 20), rewriting the same tracked line as before.
assert.deepEqual(reset.getLines(), ["OLD"]);
});
test("RIS commits the open row even when the cursor is already homed", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(20);
// xterm clears the display before homing the cursor, so output after RIS
// must not overwrite a stale suffix of the cleared screen row.
history.append("LONG\x1bcX");
assert.deepEqual(history.getLines(), ["LONG", "X"]);
});
test("a grapheme split across display chunks is rejoined", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(4);
// The backend delivered the ZWJ sequence's base in one chunk and its
// continuation in the next; xterm's grapheme provider joins them across
// writes, so the preview must measure the joined cluster, not the chunks.
history.append("👩");
history.append("‍💻Z");
assert.deepEqual(history.getLines(), ["👩‍💻Z"]);
// The unsplit input is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(4);
joined.append("👩‍💻Z");
assert.deepEqual(joined.getLines(), ["👩‍💻Z"]);
});
test("a control between chunks resets the split-grapheme join", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(5);
// xterm resets its preceding-join state at every control it parses, so an
// SGR between the base chunk and the continuation leaves two graphemes:
// 👩 (2 cells) + ‍💻 (2 cells) + Z fill the row before Q wraps.
history.append("👩");
history.append("\x1b[31m");
history.append("‍💻ZQ");
assert.deepEqual(history.getLines(), ["👩‍💻Z", "Q"]);
// The unsplit input with the same control is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(5);
joined.append("👩\x1b[31m‍💻ZQ");
assert.deepEqual(joined.getLines(), ["👩‍💻Z", "Q"]);
});
test("a width-only resize resets the tracked scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;20r");
history.append("row20content");
// xterm resets its scroll region on any buffer resize; a width-only resize
// must clear the old DECSTBM margins so relative moves clamp to the live
// bottom row instead of the region's stale one.
history.setViewportRows(24);
history.setViewportCols(40);
history.append("\x1b[20;1HX\x1b[BY");
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
});
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportCols(5);
history.append("\nabcdef\n\n\nend\n\n");
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
assert.equal(preview.totalRows, 6);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
});
test("blank-only history remains distinct from empty history", () => {
const history = createTerminalOutputHistoryPreview();
assert.equal(history.getPreviewRowCount(10), 0);
history.append("\n\n");
assert.equal(history.getPreviewRowCount(10), 2);
});
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
// a wrapped `X` on the second. The preview must keep both rows instead of
// joining the erased predecessor into its continuation.
history.append("abcde\x1b[2KX");
assert.deepEqual([...history.getLines()], ["", "X"]);
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
assert.equal(preview.totalRows, 2);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
});
test("a grapheme wider than the viewport wraps the following characters", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(1);
// xterm renders the wide glyph on the first row and wraps `X` to the
// second; retaining the whole tail as one row would shift every later
// cursor-row transition one row behind the terminal.
history.append("中X");
assert.deepEqual([...history.getLines()], ["中", "X"]);
});
test("resizing invalidates cached preview text even when returning to its old width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcdefgh", isWrapped: false }]);
// A hidden preview is not read between these resizes. Returning to the
// cached width must not resurrect characters trimmed from the live row.
history.setViewportCols(5);
history.setViewportCols(10);
assert.deepEqual(history.getLines(), ["abcde"]);
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcde", isWrapped: false }]);
});
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.equal(history.getPreviewRowCount(4), 2);
history.setViewportCols(3);
assert.equal(history.getPreviewRowCount(4), 1);
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
[{ text: "abc", isWrapped: false }]);
});
});
File diff suppressed because it is too large. Load diff
@@ -1,84 +0,0 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { Terminal } from "@xterm/xterm";
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
// Exercise the production resize listener without mounting the WebGL/UI runtime.
// The terminal buffer and history collector are real; only unrelated effects
// (atlas clearing and backend resize scheduling) are inert in this harness.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
assert.notEqual(start, -1);
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
function createHarness(cols: number) {
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
const history = createTerminalOutputHistoryPreview();
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
);
const write = async (data: string) => {
history.setViewportRows(term.rows);
history.setViewportCols(term.cols);
history.append(data);
await new Promise<void>((resolve) => term.write(data, resolve));
};
return { term, history, write };
}
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
const { term, history, write } = createHarness(10);
try {
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
term.resize(5, 24);
assert.equal(history.getPreviewRowCount(5), 1);
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
term.resize(10, 24);
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
} finally { term.dispose(); }
});
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
const { term, history, write } = createHarness(5);
try {
await write("abcdef\n");
term.resize(10, 24);
assert.equal(term.buffer.active.cursorY, 1);
await write("X\x1b[2;2HY");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write("\x1b[?7l");
for (const chunk of chunks) await write(chunk);
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
} finally { term.dispose(); }
});
}
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
} finally { term.dispose(); }
});
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
}
@@ -126,12 +126,3 @@ test("falls back to raised maxSuggestions default when unset", () => {
50,
);
});
test('unknown systems do not rewrite the command line while retaining suggestions', () => {
for (const terminalSettings of [undefined, { autocompleteEnabled: true }]) {
const settings = resolveTerminalAutocompleteSettings({protocol:'ssh', systemUnknown:true, terminalSettings});
assert.equal(settings?.enabled, true);
assert.equal(settings?.livePreview, false);
assert.equal(settings?.allowLineReplacement, false);
}
});
-81
View File
@@ -539,84 +539,3 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
logger.warn("Sync render after resize failed", err);
}
}
type XTermWithPrivateViewport = XTerm & {
_core?: {
_viewport?: {
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
};
};
};
/**
* Re-align the DOM scroll position with the buffer's viewport row.
*
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
* the scrollable viewport keeps its stale pixel offset. Any subsequent
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
* against the buffer and once against the stale DOM offset — drifting the
* reading position (all the way to the top while shrinking, #3299). Snapping
* the viewport back to the buffer row before a relative restore removes the
* desync.
*/
export function alignTerminalViewportScroll(term: XTerm): void {
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
const scrollToLine = viewport?.scrollToLine;
if (typeof scrollToLine !== "function") return;
// After a resize, xterm only refreshes the viewport's scroll dimensions on
// its queued render callback. Setting a scroll position against the stale
// dimensions gets clamped to the old maximum while xterm records the
// requested row, so the queued sync then assumes the position was already
// applied and the DOM offset stays stale — the next wheel scroll jumps
// upward by the resize delta. Sync the dimensions now, before positioning.
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
// that merely defers DOM scroll updates until the mode ends; positioning
// here would still record the requested row as _latestYDisp against the
// stale dimensions, and the deferred sync would then see
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
// offset. Leave positioning to that deferred sync instead: after reflow the
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
// with fresh dimensions on its own.
if (typeof viewport._sync === "function") {
try {
viewport._sync.call(viewport);
} catch (err) {
logger.warn("Sync viewport dimensions after resize failed", err);
}
}
if (term.modes?.synchronizedOutputMode) return;
try {
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
} catch (err) {
logger.warn("Align viewport scroll after resize failed", err);
}
}
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
export function createSynchronizedOutputFitScheduler() {
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
return {
dispose,
defer(term: XTerm, fit: () => void): boolean {
dispose();
if (!term.modes.synchronizedOutputMode) return false;
// Arms xterm's own synchronized-output timeout even if the program
// only enabled the mode without writing visible content afterward.
term.refresh(0, Math.max(0, term.rows - 1));
timer = setTimeout(() => {
timer = undefined;
// The caller re-enters safeFit and checks the mode again, including
// when another synchronized frame started before this retry.
fit();
}, 32);
return true;
},
};
}
@@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
import React, { memo, useCallback, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { activeTabStore } from '../../application/state/activeTabStore';
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
import {
getSidePanelLiveSnapshot,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
subscribeSidePanelLiveSnapshot,
} from '../../application/state/sidePanelLiveStore';
import {
@@ -50,18 +49,10 @@ const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
const getSnapshot = useCallback(() => {
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const snapshot = getSidePanelLiveSnapshot(true);
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
// Tab visibility changes before the live publisher commits its next snapshot.
// Keep this panel's last context until its own terminal/workspace catches up.
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
return retainedSnapshot.current.tabId === tabId
? retainedSnapshot.current.snapshot
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}, [subscribe, tabId]);
const getSnapshot = useCallback(
() => getSidePanelLiveSnapshot(subscribe),
[subscribe],
);
return useSyncExternalStore(
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
getSnapshot,
+11 -12
View File
@@ -29,20 +29,19 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="171.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="251.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="338.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="423.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="510.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="595.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="683.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+11 -12
View File
@@ -29,20 +29,19 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="171.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="251.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="338.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="423.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="510.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="595.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="683.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+2 -2
View File
@@ -1,4 +1,4 @@
import { classifyDistroId, resolveHostOs } from './host';
import { classifyDistroId } from './host';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
@@ -83,7 +83,7 @@ export const buildAITerminalSessionInfo = (
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: isLocalSession ? localOs : resolveHostOs(host),
os: host?.os || (isLocalSession ? localOs : undefined),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
+1 -16
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import { applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import type { Host } from "./models";
const makeHost = (id: string, overrides: Partial<Host> = {}): Host => ({
@@ -60,18 +60,3 @@ test("isSavedVaultHost is false for missing or ephemeral hosts", () => {
assert.equal(isSavedVaultHost(null), false);
assert.equal(isSavedVaultHost(undefined), false);
});
test("temporary host detection reaches AI without changing other hosts or reviving closed sessions", async () => {
const { buildAITerminalSessionInfo } = await import('./buildAITerminalSessionInfo');
const other = makeHost('other');
for (const [distro, os] of [['ubuntu', 'linux'], ['darwin', 'macos'], ['windows', 'windows']]) {
const target = makeHost('quick-connect', { ephemeral: true });
const next = applyEphemeralHostDistroUpdate([target, other], target.id, distro);
assert.equal(buildAITerminalSessionInfo(undefined, next[0], 'macos').os, os);
assert.equal(next[0].ephemeral, true);
assert.equal(next[1], other);
assert.equal(applyEphemeralHostDistroUpdate(next, target.id, distro), next);
const closed = [other];
assert.equal(applyEphemeralHostDistroUpdate(closed, target.id, distro), closed);
}
});
-8
View File
@@ -1,4 +1,3 @@
import { normalizeDistroId } from "./host";
import type { Host } from "./models";
/**
@@ -38,10 +37,3 @@ export const applyEphemeralHostsUpdate = (
const updatedById = new Map(updated.map((host) => [host.id, host]));
return previous.map((host) => updatedById.get(host.id) ?? host);
};
/** Detection updates stay in memory and never create a host after its session closes. */
export const applyEphemeralHostDistroUpdate = (previous: Host[], hostId: string, distro: string): Host[] => {
const normalized = normalizeDistroId(distro);
if (!previous.some((host) => host.id === hostId && host.distro !== normalized)) return previous;
return previous.map((host) => host.id === hostId ? { ...host, distro: normalized } : host);
};
-28
View File
@@ -1,5 +1,4 @@
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
@@ -106,7 +105,6 @@ export const normalizeDistroId = (value?: string) => {
) {
return 'macos';
}
if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows';
if (v.includes('freebsd')) return 'freebsd';
if (v.includes('ubuntu')) return 'ubuntu';
if (v.includes('debian')) return 'debian';
@@ -217,29 +215,6 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other';
};
export const HOST_OS_SELECTIONS = ['auto', 'linux', 'windows', 'macos', 'freebsd', 'unknown'] as const;
export function getHostOsSelection(host?: Pick<Host, 'os' | 'osOverride'> | null): HostOsSelection {
if (host?.osOverride && (HOST_OS_SELECTIONS as readonly string[]).includes(host.osOverride)) {
return host.osOverride;
}
return host?.os === 'windows' || host?.os === 'macos' ? host.os : 'auto';
}
/** Resolve system facts separately from cosmetic manualDistro/icon choices. */
export function resolveHostOs(
host?: Pick<Host, 'os' | 'osOverride' | 'distro' | 'deviceType' | 'protocol'> | null,
): HostOperatingSystem {
if (host?.protocol === 'local') return host.os;
const selection = getHostOsSelection(host);
if (selection !== 'auto') return selection;
if (host?.deviceType === 'network' || classifyDistroId(host?.distro) === 'network-device') return 'unknown';
const distro = normalizeDistroId(host?.distro);
if (distro === 'windows' || distro === 'macos' || distro === 'freebsd') return distro;
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(distro)) return 'linux';
return 'unknown';
}
/**
* Decide whether to offer the "enable Network Device Mode" suggestion after
* distro/vendor detection. We only nag once per host, and never when the user
@@ -455,7 +430,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? 'auto'
: undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const osSelection = getHostOsSelection(host);
const migrated = migrateDeprecatedFontOverride(host);
// Before explicit per-host authentication modes existed, new hosts were
// persisted with authMethod="password" even though an empty password still
@@ -494,8 +468,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
-64
View File
@@ -1,64 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getHostOsSelection, resolveHostOs, sanitizeHost } from './host';
import { buildAITerminalSessionInfo } from './buildAITerminalSessionInfo';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import type { Host } from './models';
const host = (changes: Partial<Host> = {}): Host => ({
id: 'host', label: 'Host', hostname: 'example.test', username: 'user', tags: [], os: 'linux', ...changes,
});
test('legacy Linux defaults are not evidence; Windows and macOS corrections survive loading', () => {
for (const os of ['linux', 'windows', 'macos'] as const) {
const restored = sanitizeHost(host({ os }));
assert.equal(getHostOsSelection(restored), os === 'linux' ? 'auto' : os);
assert.equal(resolveHostOs(restored), os === 'linux' ? 'unknown' : os);
assert.deepEqual(sanitizeHost(restored), restored);
}
});
test('detected facts drive AI and ignore cosmetic icon overrides', () => {
for (const [distro, expected] of [['ubuntu','linux'],['windows','windows'],['darwin','macos'],['freebsd','freebsd'],['cisco','unknown']]) {
const target = host({ distro, manualDistro: 'macos', distroMode: 'manual' });
assert.equal(resolveHostOs(target), expected);
assert.equal(buildAITerminalSessionInfo(undefined, target, 'macos').os, expected);
}
});
test('explicit corrections survive detection and can return to automatic', () => {
const target = host({ os: 'windows', osOverride: 'linux', distro: 'windows' });
assert.equal(resolveHostOs(target), 'linux');
assert.equal(resolveHostOs({ ...target, osOverride: 'auto' }), 'windows');
assert.equal(resolveHostOs({ ...target, osOverride: 'unknown' }), 'unknown');
});
test('network device protection is independent of a chosen operating system', () => {
const target = host({ deviceType: 'network', distro: 'ubuntu' });
assert.equal(resolveHostOs(target), 'unknown');
const info = buildAITerminalSessionInfo(undefined, {...target, osOverride: 'linux'}, 'macos');
assert.equal(info.os, 'linux');
assert.equal(info.deviceType, 'network');
});
test('local sessions report the actual local OS rather than a saved default', () => {
assert.equal(buildAITerminalSessionInfo(undefined, host({protocol:'local'}), 'windows').os, 'windows');
});
test('vault create and update share explicit selection and auto reset', () => {
const created = buildVaultHostFromDraft({hostname:'example.test',username:'user',os:'freebsd'});
assert.equal(created.ok, true);
if (!created.ok) return;
assert.equal(resolveHostOs(created.host), 'freebsd');
const updated = applyVaultHostUpdate([created.host], [], created.host.id, {os:'auto'});
assert.equal(updated.ok, true);
if (!updated.ok) return;
assert.equal(resolveHostOs(updated.updatedHost), 'unknown');
});
test('manual selections retain compatible OS values for older readers', () => {
assert.equal(sanitizeHost(host({osOverride:'windows'})).os, 'windows');
const restored = sanitizeHost(host({os:'windows',osOverride:'auto'}));
assert.equal(getHostOsSelection(restored), 'auto');
assert.equal(resolveHostOs(restored), 'unknown');
});
-6
View File
@@ -151,9 +151,6 @@ export interface SftpBookmark {
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
export interface Host {
id: string;
label: string;
@@ -164,10 +161,7 @@ export interface Host {
identityId?: string;
group?: string;
tags: string[];
// Legacy compatibility value; use resolveHostOs for runtime decisions.
os: 'linux' | 'windows' | 'macos';
// Absent on old records: preserve Windows/macOS, treat old Linux defaults as auto.
osOverride?: HostOsSelection;
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
deviceType?: 'general' | 'network';
-16
View File
@@ -15,7 +15,6 @@ import {
importMarkdownFilesToVaultNotes,
importMarkdownPayloadsToVaultNotes,
matchesVaultNoteSearch,
createVaultNoteSearchIndex,
normalizeNoteGroups,
normalizeVaultNotes,
remapExpandedNoteGroupPaths,
@@ -618,18 +617,3 @@ test("formatMarkdownListSelection prefixes every non-empty selected line", () =>
test("formatMarkdownQuoteSelection prefixes every selected line", () => {
assert.equal(formatMarkdownQuoteSelection("one\n\ntwo"), "> one\n>\n> two");
});
test("note mention index preserves search fields, entity identity and edited snapshots", () => {
const notes = [
{ id: "a", title: "Same", content: "Alpha body", group: "Production", tags: ["API"], createdAt: 1, updatedAt: 1 },
{ id: "b", title: "Same", content: "Beta body", createdAt: 1, updatedAt: 1 },
];
const search = createVaultNoteSearchIndex(notes);
for (const query of ["", " SAME ", "ALPHA", "beta", "production", "api", "missing", "same alpha"]) {
assert.deepEqual(search(query), notes.filter((note) => matchesVaultNoteSearch(note, query)));
}
assert.equal(search("beta")[0], notes[1]);
const edited = [{ ...notes[0], content: "Replacement" }, notes[1]];
assert.deepEqual(createVaultNoteSearchIndex(edited)("alpha"), []);
assert.equal(createVaultNoteSearchIndex(edited)("replacement")[0], edited[0]);
});
-15
View File
@@ -284,21 +284,6 @@ export const matchesVaultNoteSearch = (
].some((value) => value.toLowerCase().includes(needle));
};
/** Build once per note-list snapshot, so typing does not lowercase full bodies. */
export const createVaultNoteSearchIndex = (notes: readonly VaultNote[]) => {
const entries = notes.map((note) => ({
note,
fields: [note.title, note.content, note.group ?? "", ...(note.tags ?? [])]
.map((value) => value.toLowerCase()),
}));
return (query: string): VaultNote[] => {
const needle = query.trim().toLowerCase();
return entries
.filter((entry) => !needle || entry.fields.some((field) => field.includes(needle)))
.map((entry) => entry.note);
};
};
const isSanitizedRenderedLink = (value: string): boolean => {
try {
const url = new URL(value);
@@ -131,7 +131,6 @@ test("system overview stats run for Linux and macOS targets", () => {
{
id: "host-1",
label: "Linux",
distro: "ubuntu",
hostname: "linux.local",
username: "root",
tags: [],
@@ -173,11 +172,3 @@ test("FreeBSD icon detection does not enable unsupported system features", () =>
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview", "processes"]);
});
test('default Linux and cosmetic icons cannot enable Linux commands before detection', () => {
const host = {id:'unknown', label:'Host', hostname:'host', username:'user', tags:[], os:'linux' as const, manualDistro:'ubuntu', distroMode:'manual' as const};
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ['overview','processes']);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu'},undefined,null),true);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu',osOverride:'windows'},undefined,null),false);
});
+19 -10
View File
@@ -1,8 +1,13 @@
import { classifyDistroId, resolveHostOs } from '../host';
import { classifyDistroId, getEffectiveHostDistro, normalizeDistroId } from '../host';
import type { Host } from '../models/connection';
import type { TerminalSession } from '../../types';
import type { SessionCapabilities, SystemManagerSubTab } from './types';
const hasKnownUnsupportedSystemDistro = (host: Host | null | undefined): boolean => {
const detectedDistro = normalizeDistroId(host?.distro);
return !!detectedDistro && classifyDistroId(detectedDistro) === 'other';
};
export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
if (host?.deviceType === 'network') return true;
return classifyDistroId(host?.distro) === 'network-device';
@@ -11,11 +16,14 @@ export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
export function isDefiniteLinuxTarget(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
_session: TerminalSession | null | undefined,
session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') return capabilities.targetOs === 'linux';
if (capabilities?.targetOs === 'linux') return true;
if (isNetworkDeviceTarget(host)) return false;
if (resolveHostOs(host) === 'linux') return true;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux') return true;
if (classifyDistroId(getEffectiveHostDistro(host)) === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
return false;
}
@@ -40,7 +48,7 @@ export function shouldShowTmuxTab(
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
if (capabilities?.targetOs === 'darwin') return true;
if (resolveHostOs(host) === 'macos') return true;
if (host?.os === 'macos') return true;
return false;
}
@@ -90,14 +98,15 @@ export function shouldShowServicesTab(
export function shouldCollectServerStats(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
_session: TerminalSession | null | undefined,
session: TerminalSession | null | undefined,
): boolean {
const detectedDeviceClass = classifyDistroId(host?.distro);
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
}
if (resolveHostOs(host) === 'linux' || resolveHostOs(host) === 'macos') return true;
if (capabilities?.targetOs === 'linux' || capabilities?.targetOs === 'darwin') return true;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux' || host?.os === 'macos') return true;
if (detectedDeviceClass === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
return false;
}
+5 -9
View File
@@ -1,6 +1,5 @@
import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
import { sanitizeHost } from './host';
import type { HostOsSelection } from './models/connection';
import {
findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference,
@@ -94,10 +93,9 @@ const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined =>
return undefined;
};
const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
const normalizeOs = (raw: unknown): Host['os'] | undefined => {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
if (value === 'linux') return 'linux';
if (value === 'windows' || value === 'win') return 'windows';
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
@@ -231,9 +229,9 @@ export function buildVaultHostFromDraft(
&& draft.os !== null
&& !(typeof draft.os === 'string' && !draft.os.trim());
if (hasOsInput && parsedOs === undefined) {
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
return { ok: false, error: 'os must be linux, windows, or macos.' };
}
const os = parsedOs === 'windows' || parsedOs === 'macos' ? parsedOs : 'linux';
const os = parsedOs ?? 'linux';
const parsedPort = parsePort(draft.port);
const hasPortInput = draft.port !== undefined
&& draft.port !== null
@@ -310,7 +308,6 @@ export function buildVaultHostFromDraft(
group: normalizeGroupPath(draft.group),
tags: tags.tags,
os,
osOverride: parsedOs ?? 'auto',
protocol,
createdAt: now,
...(keyPath
@@ -441,10 +438,9 @@ export function applyVaultHostUpdate(
if (os.provided) {
const nextOs = normalizeOs(os.value);
if (!nextOs) {
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
return { ok: false, error: 'os must be linux, windows, or macos.' };
}
updated.osOverride = nextOs;
updated.os = nextOs === 'windows' || nextOs === 'macos' ? nextOs : 'linux';
updated.os = nextOs;
}
if (identityId.provided) {
-62
View File
@@ -1,62 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readVaultNote } from './vaultNoteRead';
const note = { id: 'note-1', title: 'Runbook', content: '', createdAt: 1, updatedAt: 2 };
function read(...args: Parameters<typeof readVaultNote>) {
const result = readVaultNote(...args);
assert.ok('note' in result, JSON.stringify(result));
return result;
}
test('bounded pages reconstruct long Unicode notes without gaps or broken characters', () => {
const content = `${'a'.repeat(5999)}\u{1F408}中文\n`.repeat(20);
let offset: number | null = 0;
let collected = '';
while (offset !== null) {
const result = read({ ...note, content }, { offset, maxChars: 1_000_000, expectedUpdatedAt: 2 });
assert.ok(result.note);
assert.equal(result.offset, offset);
assert.ok(result.note.content.length <= 6000);
assert.doesNotMatch(result.note.content, /^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/);
collected += result.note.content;
offset = result.nextOffset;
}
assert.equal(collected, content);
});
test('small/empty notes and EOF are complete, and oversized metadata stays out', () => {
for (const content of ['', 'small note']) {
const result = read({ ...note, content, title: 'x'.repeat(100_000), tags: ['x'.repeat(100_000)] }, {});
assert.ok(result.note);
assert.equal(result.note.content, content);
assert.equal(result.nextOffset, null);
assert.ok(JSON.stringify(result).length < 1000);
}
assert.equal(read({ ...note, content: 'abc' }, { offset: 3 }).note?.content, '');
});
test('literal search locates later and overlapping hits without skipping nearby matches', () => {
const source = { ...note, content: `${'x'.repeat(9000)}ababa\u{1F408}tail` };
const first = read(source, { query: 'aba', maxChars: 4 });
assert.equal(first.matchOffset, 9000);
assert.equal(first.note?.content, 'abab');
const second = read(source, { query: 'aba', offset: first.nextOffset, expectedUpdatedAt: 2 });
assert.equal(second.matchOffset, 9002);
const missing = read(source, { query: 'aba', offset: second.nextOffset });
assert.equal(missing.matchOffset, null);
assert.equal(missing.nextOffset, null);
assert.equal(missing.note?.content, '');
assert.equal(read(source, { query: 'ABA' }).matchOffset, null);
assert.equal(read(source, { query: '.*' }).matchOffset, null);
});
test('invalid ranges, queries and changed notes fail explicitly', () => {
const source = { ...note, content: 'a\u{1F408}b' };
for (const params of [
{ offset: -1 }, { offset: 0.5 }, { offset: 9 }, { offset: 2 }, { offset: '1' },
{ maxChars: 0 }, { maxChars: NaN }, { maxChars: Infinity },
{ query: '' }, { query: 'a'.repeat(201) }, { query: 1 }, { expectedUpdatedAt: 1 },
]) assert.equal(readVaultNote(source, params).ok, false, JSON.stringify(params));
});
-52
View File
@@ -1,52 +0,0 @@
import type { VaultNote } from './models';
// Stay below the shared tool fitter's 8,000-character string limit.
export const MAX_NOTE_READ_CHARS = 6000;
/** Bounded reads use JavaScript (UTF-16) offsets, including for literal search. */
export function readVaultNote(note: VaultNote, params: Record<string, unknown>) {
const offset = params.offset ?? 0;
const maxChars = params.maxChars ?? MAX_NOTE_READ_CHARS;
const query = params.query;
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > note.content.length) {
return { ok: false, error: 'offset must be an integer within the note content.' };
}
if (typeof maxChars !== 'number' || !Number.isSafeInteger(maxChars) || maxChars < 2) {
return { ok: false, error: 'maxChars must be an integer of at least 2 (capped at 6000).' };
}
if (query !== undefined && (typeof query !== 'string' || query.length === 0 || query.length > 200)) {
return { ok: false, error: 'query must be a nonempty literal string of at most 200 characters.' };
}
if (params.expectedUpdatedAt !== undefined && params.expectedUpdatedAt !== note.updatedAt) {
return { ok: false, error: 'The note changed. Restart reading from offset 0 without expectedUpdatedAt.' };
}
const splitsPair = (position: number) => position > 0
&& /[\uD800-\uDBFF]/.test(note.content[position - 1] ?? '')
&& /[\uDC00-\uDFFF]/.test(note.content[position] ?? '');
if (splitsPair(offset)) return { ok: false, error: 'offset splits a Unicode character. Use the returned nextOffset.' };
const matchOffset = typeof query === 'string' ? note.content.indexOf(query, offset) : undefined;
let start = matchOffset === undefined ? offset : matchOffset < 0 ? note.content.length : matchOffset;
if (splitsPair(start)) start -= 1;
let end = Math.min(note.content.length, start + Math.min(maxChars, MAX_NOTE_READ_CHARS));
if (splitsPair(end)) end -= 1;
let nextOffset: number | null = end < note.content.length ? end : null;
if (matchOffset !== undefined) {
// Advance past the match start, not the excerpt, so nearby/overlapping hits remain discoverable.
let searchFrom = matchOffset + 1;
if (splitsPair(searchFrom)) searchFrom += 1;
nextOffset = matchOffset >= 0 && searchFrom < note.content.length ? searchFrom : null;
}
return {
ok: true,
// Large optional metadata must not defeat the body limit.
note: { id: note.id, title: Array.from(note.title).slice(0, 120).join(''),
content: note.content.slice(start, end), createdAt: note.createdAt, updatedAt: note.updatedAt },
offset: start,
endOffset: end,
totalChars: note.content.length,
nextOffset,
hasMore: nextOffset !== null,
...(matchOffset === undefined ? {} : { matchOffset: matchOffset < 0 ? null : matchOffset }),
};
}
+6 -13
View File
@@ -1,23 +1,16 @@
"use strict";
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
// Both fish and POSIX shells accept this command. Inspect the parent of a
// short-lived sh in the interactive PTY, rather than the SSH login shell.
function buildLiveShellProbe(marker) {
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
+ `printf "${marker}_P:%s\\n" "$name"`;
// command eval bypasses an eval customization; plain eval is the fallback
// when command itself is shadowed. Never invoke a shadowed builtin after the
// command path already succeeded. Both eval bodies remain Bash-guarded.
const cleanup = buildBashHistoryCleanup(marker, true);
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Continuation lines stay within canonical input limits. Each echo carries
// the marker so the renderer also hides continuation prompts.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
// Put the job marker near the start of the echo, as the existing wrappers
// do, so preload can suppress it before a long command is split into chunks.
// The builtin completion marker still runs if sh cannot launch. Leave it
// unterminated so preload hides the intermediate prompt and wrapper echo
// on the same marker-bearing line, including across output chunks.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
}
function parseLiveShellProbe(output, marker) {
+1 -256
View File
@@ -34,14 +34,10 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
const { spawnSync } = require('node:child_process');
const pty = new EventEmitter();
let job;
let pendingInput = '';
pty.write = (data) => {
if (String(data).includes('command sh -c')) return;
if (data === '\x03') return;
pendingInput += String(data);
if (!pendingInput.endsWith('\n')) return;
const script = pendingInput.replace(/^\x0b\x15/, '');
pendingInput = '';
const script = String(data).replace(/^\x15\x0b/, '');
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
};
@@ -166,254 +162,3 @@ test('real PTY: echo-disabled bash completes output without a trailing newline',
pty.kill();
}
});
for (const editing of [true, false]) {
test(`real PTY: pending input clearing leaves no control command (editing=${editing})`, {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 5000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', ...(editing ? [] : ['--noediting'])], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CLEAR_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData(data => { output += data; });
try {
const waitForPrompt = async () => {
const deadline = Date.now() + 3000;
while (!output.includes('CLEAR_READY>')) {
if (Date.now() > deadline) throw new Error(`Missing prompt: ${output}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
};
await waitForPrompt();
output = '';
// Leave text on both sides of the readline cursor. Without editing, all
// bytes remain pending canonical input and must still be discarded.
pty.write('left-right\x1b[5D');
await new Promise(resolve => setTimeout(resolve, 50));
const { buildPendingInputClearPrefix } = require('./ptyExecHelpers.cjs');
pty.write(buildPendingInputClearPrefix('posix') + "printf 'CLEAR_SUCCESS\\n'\n");
await waitForPrompt();
assert.match(output, /CLEAR_SUCCESS\r\n/);
assert.doesNotMatch(output, /command not found|syntax error/);
} finally {
pty.kill();
}
});
}
test('real PTY: canonical bash executes a long literal command without truncation', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 15000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CANONICAL_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData((data) => { output += data; });
try {
const deadline = Date.now() + 3000;
while (!output.includes('CANONICAL_READY>')) {
if (Date.now() > deadline) throw new Error('Missing canonical shell prompt');
await new Promise((resolve) => setTimeout(resolve, 10));
}
const literal = 'x'.repeat(12000);
const result = await require('./ptyExec.cjs').execViaPty(pty, `printf '%s' '${literal}'`, {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 1500,
});
assert.equal(result.exitCode, 0, JSON.stringify({ ...result, stdout: result.stdout?.slice(-200) }));
assert.equal(result.stdout.trim(), literal);
} finally {
pty.kill();
}
});
// The second element is a history-listing invocation that still reaches the
// real history builtin under that customization.
for (const [customization, listHistory] of [
[':', 'command builtin history'],
['HISTCONTROL=ignorespace', 'command builtin history'],
["alias history='history 10'", 'command builtin history'],
['history() { :; }', 'command builtin history'],
['history() { builtin history "$@" | cat; }', 'command builtin history'],
["alias eval=':'", 'command builtin history'],
['eval() { :; }', 'command builtin history'],
['PATH=/nonexistent', 'command builtin history'],
["alias builtin=':'", 'command builtin history'],
['builtin() { :; }', 'command builtin history'],
["alias command=':'", '\\builtin history'],
['command() { :; }', '\\builtin history'],
]) {
for (const executeCommand of [false, true]) {
test(`bash probe keeps user history clean (${customization}, wrapper=${executeCommand})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_HISTORY_PROBE__';
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
+ buildLiveShellProbe(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
}
test('real PTY: probe and execution leave only user commands for arrow recall', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
}, async () => {
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null', HISTCONTROL: '',
PS1: 'HISTORY_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
terminal.onData(data => { output += data; });
const waitFor = async text => {
const deadline = Date.now() + 3000;
while (!output.includes(text)) {
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
const result = output;
output = '';
return result;
};
try {
await waitFor('HISTORY_READY>');
terminal.write('builtin history -c\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_one\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_two\r');
await waitFor('HISTORY_READY>');
const result = await require('./ptyExec.cjs').execViaPty(terminal, 'printf agent_success', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /agent_success/);
await waitFor('HISTORY_READY>');
terminal.write('\x1b[A\r');
const recalled = await waitFor('HISTORY_READY>');
assert.match(recalled, /user_history_two/);
assert.doesNotMatch(recalled, /__NCMCP_/);
terminal.write('builtin history\r');
const history = await waitFor('HISTORY_READY>');
assert.match(history, /echo user_history_one/);
assert.match(history, /echo user_history_two/);
assert.doesNotMatch(history, /__NCMCP_/);
} finally {
terminal.kill();
}
});
for (const invocationName of ['sh', 'renamed-bash']) {
test(`Bash invoked as ${invocationName} cleans probe history`, () => {
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const directory = require('../tempDirBridge.cjs').getTempFilePath('probe-bash');
fs.mkdirSync(directory, { mode: 0o700 });
try {
const shell = path.join(directory, invocationName);
fs.symlinkSync('/bin/bash', shell);
const marker = '__NCMCP_RENAMED_BASH__';
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo preserve_user_history')), result.stdout);
assert.ok(entries.every(line => !line.includes(marker)), result.stdout);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
}
for (const [shell, args] of [
['/bin/dash', ['-i']],
['/bin/zsh', ['-f', '-i']],
['/bin/bash', ['--noprofile', '--norc', '-i']],
]) {
test(`probe preserves an interactive errexit session in ${shell}`, (t) => {
const { spawnSync } = require('node:child_process');
const marker = '__NCMCP_ERREXIT_PROBE__';
const result = spawnSync(shell, args, {
input: 'set -e\n' + buildLiveShellProbe(marker) + '\necho shell_survived\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null' }, timeout: 5000,
});
if (result.error?.code === 'ENOENT') return t.skip(`${shell} is unavailable`);
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.ok(result.stdout.includes('shell_survived'), result.stdout);
});
}
for (const shell of ['/bin/dash', '/bin/zsh']) {
test(`history cleanup keeps the execution wrapper portable in ${shell}`, { skip: !require('node:fs').existsSync(shell) }, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_PORTABLE_HISTORY__';
const result = spawnSync(shell, ['-c', buildWrappedCommand('echo portable-history-ok', 'posix', marker, true)], { encoding: 'utf8', timeout: 5000 });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /portable-history-ok/);
assert.ok(result.stdout.includes(`${marker}_E:0`));
});
}
for (const historyControl of ['', 'ignorespace']) {
test(`successful cleanup does not invoke a shadowed builtin (${historyControl || 'default'})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_FALLBACK_GUARD__';
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
+ buildLiveShellProbe(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.match(result.stdout, /command_ok/);
assert.doesNotMatch(result.stdout, /UNEXPECTED_FALLBACK/);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
const job = startPtyJob(pty, 'echo must_not_run', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
}
await job.resultPromise;
const count = writes.length;
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(writes.length, count);
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}
+4 -55
View File
@@ -94,7 +94,6 @@ function startPtyJob(ptyStream, command, options) {
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
let probingShell = usesLiveShellProbe;
let deliveringInput = false;
let probeOutput = "";
let output = "";
@@ -201,9 +200,6 @@ function startPtyJob(ptyStream, command, options) {
// Foreground execs use the configured timeoutMs as the deadline (matching
// the pre-PR behavior); background jobs use a fixed 30s since their main
// timeout is much longer (1 hour) and meant for the actual command.
// The timer is armed once input delivery completes (see writeInput) so the
// paced-typing time for oversized probes/wrappers is excluded from the
// startup budget instead of counting against it.
const BG_STARTUP_TIMEOUT_MS = 30000;
function armStartupTimeout() {
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
@@ -530,7 +526,7 @@ function startPtyJob(ptyStream, command, options) {
: Buffer.from(String(data ?? ""));
const text = outputDecoder.write(bytes);
if (!text) return;
if (!pendingEnd && !deliveringInput) armOutputTimeout();
if (!pendingEnd) armOutputTimeout();
if (probingShell) {
probeOutput = (probeOutput + text).slice(-16384);
@@ -656,6 +652,7 @@ function startPtyJob(ptyStream, command, options) {
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
unsubscribe = subscribeToPtyData(ptyStream, onData);
@@ -732,61 +729,13 @@ function startPtyJob(ptyStream, command, options) {
}
}
let inputWriteTimer = null;
let inputWriteGeneration = 0;
function stopInputWrite() {
inputWriteGeneration += 1;
clearTimeout(inputWriteTimer);
inputWriteTimer = null;
}
cleanupFns.push(stopInputWrite);
function writeInput(text) {
stopInputWrite();
// Each delivery gets a fresh wait budget, including the transition from
// probe to wrapper. Echo may be disabled while we are still typing.
clearStartupTimeout();
clearTimeout(timeoutId);
deliveringInput = true;
const generation = inputWriteGeneration;
let offset = 0;
// Readline may overrun its input queue when long probes arrive in one
// write. Yield between bounded chunks; cancelled/replaced jobs must never
// finish typing an old command into a subsequent prompt.
const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length;
const writeNext = () => {
if (finished || cancelRequested || generation !== inputWriteGeneration) return;
let end = Math.min(offset + chunkSize, text.length);
// Do not split a UTF-16 surrogate pair across independently encoded writes.
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
ptyStream.write(text.slice(offset, end));
offset = end;
if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) {
inputWriteTimer = setTimeout(() => {
try { writeNext(); } catch (error) {
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
}
}, 30);
} else {
// Input delivery is complete: only now does the startup deadline
// begin, so paced typing time never consumes the startup budget.
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
deliveringInput = false;
armOutputTimeout();
if (!foundStart) armStartupTimeout();
}
}
};
writeNext();
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
}
if (probingShell) {
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
} else {
writeWrappedCommand();
}
+5 -16
View File
@@ -26,7 +26,7 @@ const {
class ShellBackedPty extends EventEmitter {
write(data) {
if (data === "\x03") return;
const script = String(data).replace(/^\x0b\x15/, "");
const script = String(data).replace(/^\x15\x0b/, "");
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
queueMicrotask(() => {
this.emit("data", Buffer.from(result.stdout));
@@ -365,8 +365,8 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
});
test("pending-input clear prefix covers interactive shells and skips raw devices", () => {
assert.equal(buildPendingInputClearPrefix("posix"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("fish"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("posix"), "\x15\x0b");
assert.equal(buildPendingInputClearPrefix("fish"), "\x15\x0b");
assert.equal(buildPendingInputClearPrefix("powershell"), "\x1bggd2147483647d\x1br\x1b\x1bi\x08");
assert.equal(buildPendingInputClearPrefix("cmd"), "\x1b");
assert.equal(buildPendingInputClearPrefix("raw"), "");
@@ -849,7 +849,7 @@ test("startPtyJob keeps the clear prefix for non-PowerShell sessions", async ()
expectedPrompt: "$ ",
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x0b\x15"));
assert.ok(writes[0].startsWith("\x15\x0b"));
assert.match(writes[0], /__NCMCP_/);
job.cancel();
pty.emit("data", Buffer.from("$ "));
@@ -921,17 +921,6 @@ test("posix wrapper types multi-line commands as one physical line (no PS2 leak)
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
});
test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on bounded input lines", () => {
const marker = "__NCMCP_LONG_LITERAL_TEST__";
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
const wrapped = buildWrappedCommand(command, "posix", marker, true);
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
});
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("exit 7", "posix", marker);
@@ -1306,7 +1295,7 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
assert.equal(result.error, "Cancelled");
});
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", 'history() { builtin history "$@" | cat; }', "history() { :; }", "alias command=':'", "command() { :; }", "readonly __nc_h=USER", "readonly __nc_d=USER"]) {
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", "history() { :; }"]) {
test(`posix wrapper bypasses history customization: ${customization}`, () => {
const marker = "__NCMCP_CUSTOM_HISTORY__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
+22 -54
View File
@@ -194,64 +194,34 @@ function buildPendingInputClearPrefix(shellKind) {
// i+Backspace leaves every mode on an empty editable line.
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
default:
// Kill the suffix before the prefix. Canonical/no-editing terminals do
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
return "\x0b\x15";
return "\x15\x0b";
}
}
function bashHistoryScratchNames(marker) {
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
}
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
// executes builtins before trusting an empty history read from a no-op function.
// After deletion, verify the entry is gone: a shadowed history function may
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
// Invocation-specific scratch names avoid readonly user variables. Clear the
// history-bearing scratch through the verified dispatcher, never plain unset.
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
let cmdAssign = commandLines.length > 1
const cmdAssign = commandLines.length > 1
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
// Canonical PTYs limit bytes per physical input line, regardless of write
// pacing. Emit bounded quoted pieces inside one command substitution; each
// continuation keeps the marker visible to the terminal echo filter.
const writes = [];
for (const [index, line] of commandLines.entries()) {
let chunk = '';
let bytes = 0;
for (const character of line) {
const quoted = escapePosixSingleQuoted(character);
const size = Buffer.byteLength(quoted, 'utf8');
if (bytes + size > 512) {
writes.push(`printf '%s' '${chunk}'`);
chunk = '';
bytes = 0;
}
chunk += quoted;
bytes += size;
}
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
const separator = prefix.length + suffix.length + 2 > 1000
? `; \\\n: '${marker}'; ` : "; ";
return `${prefix}${separator}${suffix}`;
// Issue #3265: without HISTCONTROL=ignorespace the wrapper line is recorded
// in bash history. Pressing arrow-up then makes readline redraw that huge
// marker-containing line, and the preload __NCMCP_ filter suppresses the
// redraw fragments that contain the marker — readline's row accounting
// diverges from what the terminal actually rendered, so every subsequent
// history-navigation redraw leaves fragments of the AI command on screen.
// Match the latest entry before deleting it, preserving user history when
// HISTCONTROL=ignorespace skips the wrapper. Read the entry's actual number:
// older Bash versions can expose HISTCMD as the next history number.
// builtin history bypasses user aliases and functions. The unset-safe guard
// leaves non-Bash shells alone, including shells with nounset enabled.
// Use ^ for the Bash bracket negation: ! triggers interactive zsh history
// expansion before the Bash-only guard can run.
const historyCleanup =
`[ -n "\${BASH_VERSION-}" ] && { ${marker}_h=$(builtin history 1 2>/dev/null); case "$${marker}_h" in *${marker}*) ${marker}_h=\${${marker}_h#"\${${marker}_h%%[^[:space:]]*}"}; builtin history -d "\${${marker}_h%%[[:space:]]*}" 2>/dev/null ;; esac; }`;
return (
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; ${historyCleanup}; (exit $__NCMCP_rc); }`
);
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
@@ -289,7 +259,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
case "posix":
default: {
// Compound command with an early marker on each physical line.
// Single-line compound command with early marker.
//
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
@@ -306,7 +276,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 3) The complete { ... } group is parsed before execution, so SIGINT
// 3) Single-line { ... } is parsed fully before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
@@ -500,8 +470,6 @@ module.exports = {
resolveEffectiveShellKind,
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
appendBoundedOutput,
@@ -1,62 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
for (const background of [true, false]) {
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const command = `echo ${'x'.repeat(background ? 150000 : 12000)}`;
const job = startPtyJob(pty, command, {
shellKind: 'posix', probeLiveShell: true,
timeoutMs: background ? 3600000 : 500,
maxBufferedChars: background ? 1024 : 0,
});
const advance = (ms) => {
for (let elapsed = 0; elapsed < ms; elapsed += 30) t.mock.timers.tick(30);
};
try {
const probeLength = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < probeLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'probe delivery was interrupted');
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
// The background case crosses the old probe timer; the foreground
// case has no echo to refresh its inactivity timer during delivery.
advance(background ? 32010 : 1200);
assert.ok(!writes.includes('\x03'), 'wrapper interrupted before delivery completed');
const totalLength = probeLength + 2 + buildWrappedCommand(command, 'posix', job.marker, true).length;
while (writes.join('').length < totalLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'delivery did not finish');
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
} finally {
pty.emit('close');
t.mock.timers.reset();
}
});
}
test('completed delivery still has a bounded wait for a missing probe reply', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'echo never', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 500,
});
const length = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < length) t.mock.timers.tick(30);
// Unrelated output must not keep a never-started command alive forever.
for (let i = 0; i < 6; i++) {
pty.emit('data', 'unrelated output\n');
t.mock.timers.tick(100);
}
const result = await job.resultPromise;
assert.match(result.error, /Command startup timed out/);
assert.ok(writes.includes('\x03'));
});
@@ -307,21 +307,3 @@ test("readRemoteHistory reports pending while the Mosh handshake is still in pro
assert.equal(result.pending, true);
assert.equal(session.conn, undefined);
});
test("getSessionDistroInfo uses and reuses the Mosh stats companion", async () => {
const session = { type: "mosh", moshStatsAuth: { hostname: "h" } };
let connections = 0;
const api = makeApi(session, async () => { throw new Error("not ET"); }, {
ensureMoshStatsConnection: async (target) => {
connections++;
target.moshStatsConn = fakeConn('ID=ubuntu');
},
});
for (let i = 0; i < 2; i++) {
const result = await api.getSessionDistroInfo(null, { sessionId: 'et-1' });
assert.equal(result.success, true);
assert.equal(result.stdout, 'ID=ubuntu');
}
assert.equal(connections, 1);
assert.equal(session.conn, undefined);
});
+3 -13
View File
@@ -91,9 +91,7 @@ function withRemoteWatchdog(command, timeoutMs) {
`( sleep ${seconds}`,
// Resolve the watchdog subshell's own PID so the tree walk can skip it
// (killing ourselves mid-list would abort the remaining kills).
// exec replaces the command-substitution shell: without it Bash may
// report that intermediate PID instead of the watchdog subshell.
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
`&& nc_self=$(sh -c 'echo $PPID' 2>/dev/null)`,
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
].join(' ');
@@ -171,15 +169,7 @@ function createSessionOpsApi(ctx) {
knownHosts: session.etStatsAuth?.knownHosts,
});
}
if (session?.type === 'mosh' && !session.moshStatsConn && typeof ensureMoshStatsConnection === 'function') {
try {
await ensureMoshStatsConnection(session, sessionId, _event?.sender);
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
const conn = session?.conn || session?.moshStatsConn;
if (!conn) {
if (!session || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
const command = withPosixRemoteWatchdog(
@@ -187,7 +177,7 @@ function createSessionOpsApi(ctx) {
5000,
);
try {
const { stdout, stderr } = await executeBoundedSshCommand(conn, command, {
const { stdout, stderr } = await executeBoundedSshCommand(session.conn, command, {
openingTimeoutMs: 5000,
runTimeoutMs: 5000,
maxOutputBytes: 256 * 1024,
@@ -1107,60 +1107,3 @@ test("getSessionDistroInfo wraps the os-release probe in a remote watchdog", asy
);
assert.ok(commands[0].includes("cat /etc/os-release"));
});
for (const blocked of [false, true]) {
test(`real distro probe ${blocked ? 'kills blocked children' : 'cleans its watchdog after success'}`, {
skip: process.platform === 'win32', timeout: 10000,
}, async () => {
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const directory = require('../tempDirBridge.cjs').getTempFilePath('watchdog-test');
fs.mkdirSync(directory, { mode: 0o700 });
fs.writeFileSync(path.join(directory, 'cat'), blocked
? '#!/bin/sh\nexec /bin/sleep 30\n'
: '#!/bin/sh\nprintf probe_completed\n', { mode: 0o700 });
const commands = [];
const api = makeSessionOps(new Map([['watchdog-smoke', {
type: 'ssh', conn: { exec(command, cb) { commands.push(command); cb(null, fakeStream('fixture')); } },
}]]));
const captured = await api.getSessionDistroInfo({}, { sessionId: 'watchdog-smoke' });
assert.equal(captured.success, true);
assert.equal(commands.length, 1);
const started = Date.now();
const child = spawn('/bin/sh', ['-c', commands[0]], {
detached: true, env: { ...process.env, PATH: `${directory}:${process.env.PATH}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '', outcome = null;
child.stdout.on('data', data => { output += data; });
child.stderr.resume();
const finished = new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, signal) => { outcome = { code, signal }; resolve(); });
});
let timer;
try {
await Promise.race([finished, new Promise(resolve => { timer = setTimeout(resolve, 7000); })]);
const ps = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { encoding: 'utf8' });
assert.equal(ps.status, 0, ps.stderr);
const living = ps.stdout.split('\n').map(line => line.trim().split(/\s+/))
.filter(row => Number(row[1]) === child.pid && !row[2].startsWith('Z'));
assert.ok(outcome, `probe did not close; descendants still alive: ${JSON.stringify(living)}`);
assert.deepEqual(living, [], 'probe must not leave a child or watchdog sleep running');
if (blocked) {
assert.equal(outcome.signal, 'SIGKILL');
assert.ok(Date.now() - started >= 4500, 'remote watchdog must own the timeout');
} else {
assert.equal(outcome.code, 0);
assert.equal(output, 'probe_completed');
assert.ok(Date.now() - started < 2000, 'success must not wait for the watchdog deadline');
}
} finally {
clearTimeout(timer);
try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
await finished;
fs.rmSync(directory, { recursive: true, force: true });
}
});
}
+1 -1
View File
@@ -206,7 +206,7 @@ const VAULT_CAPABILITIES = [
id: "vault.note.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
description: "Get a Vault → Notes entry by id (full markdown content).",
policy: {
write: false,
sensitiveRead: false,
+1 -5
View File
@@ -119,7 +119,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
tags: { type: "string", optional: true, description: "JSON array or comma-separated tag names. Empty string clears tags." },
notes: { type: "string", optional: true, description: "Host Details remarks. Empty string clears notes." },
protocol: { type: "string", optional: true, description: "New protocol: ssh, telnet, local, or serial." },
os: { type: "string", optional: true, description: "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate." },
os: { type: "string", optional: true, description: "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior." },
identityId: { type: "string", optional: true, description: "Reusable identity ID from vault_identities_list. Empty string detaches the identity." },
jumpHostIds: { type: "string", optional: true, description: "JSON array of vault host IDs in jump order. Empty array clears the chain." },
proxyProfileId: { type: "string", optional: true, description: "Reusable proxy ID from vault_proxy_profiles_list. Empty string clears it." },
@@ -167,10 +167,6 @@ const TOOL_INPUT_FIELDS = Object.freeze({
"vault.note.list": {},
"vault.note.get": {
noteId: { type: "string", description: "Vault note ID from vault_notes_list." },
offset: { type: "number", optional: true, description: "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset." },
maxChars: { type: "number", optional: true, description: "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000." },
query: { type: "string", optional: true, description: "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search." },
expectedUpdatedAt: { type: "number", optional: true, description: "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed." },
},
"vault.note.create": {
title: { type: "string", description: "Note title shown in Vault → Notes." },
@@ -90,13 +90,7 @@ function createVaultService(ctx = {}) {
getNote: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.get", {
noteId: params.noteId,
offset: params.offset,
maxChars: params.maxChars,
query: params.query,
expectedUpdatedAt: params.expectedUpdatedAt,
});
return invokeVaultAgent("note.get", { noteId: params.noteId });
},
createNote: async (params = {}) => {
const bridgeErr = requireBridge();
+1 -1
View File
@@ -59,7 +59,7 @@ ${permissionRules}
**Attached host files:** When the user asks to import attached host/server data, call \`list_attachments\` then \`read_attachment\`. If the attachment is a known export format, pass the exact text to \`vault_hosts_import\`. If the format is unknown or \`vault_hosts_import\` cannot detect it, do not search a terminal or remote filesystem; read the attached text, extract host fields yourself, and call \`vault_hosts_create\` with \`dryRun: true\` first. If a tool result is truncated or compressed and includes a \`tool_output_read\` handle, use \`tool_output_read\` to recover the needed original text before extracting fields.
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host. When a message references a Vault note, use \`vault_notes_get\` with its exact \`noteId\` to read the latest content before summarizing or editing. Use that same ID for updates; do not substitute a title search. If the note no longer exists, tell the user.
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host.
**Snippets vs automation scripts:** Use \`snippets_*\` for shell command text (paste/execute with optional \`{{variables}}\`). Use \`scripts_*\` for multi-step terminal automation written in JavaScript with the \`nct.*\` API (\`await nct.screen.sendLine\`, \`waitForText\` / \`waitForRegex\`, dialogs, progress). Call \`scripts_reference\` before authoring or editing scripts. Run scripts with \`scripts_run\` (set \`wait: true\` to block until done); use \`scripts_runs_list\`, \`scripts_run_stop\`, \`scripts_run_pause\`, and \`scripts_run_resume\` for lifecycle control. Create/update/delete vault entries with \`snippets_create/update/delete\` (any kind) or \`scripts_create/update/delete\` (scripts only).
@@ -6,24 +6,6 @@ import { TraceStore } from './traceStore.ts';
import { ToolOutputStore } from './toolOutputStore.ts';
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext.ts';
test('normal turn and step preparation preserve exact user literals alongside noisy tool output', async () => {
const literal = "printf '%s\\n' 'CANONICAL_GUI_" + 'x'.repeat(1200) + "_END'";
const content = `Execute exactly: ${literal}\n\n\n\n${'keep this line\n'.repeat(5)}`;
for (const userContent of [content, [{ type: 'text' as const, text: content }]]) {
const messages: ModelMessage[] = [
{ role: 'user', content: userContent },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'noise', toolName: 'terminal_execute', input: {} }] },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'noise', toolName: 'terminal_execute', output: { type: 'text', value: 'log '.repeat(10000) } }] },
];
const turn = await prepareTurnContext({ messages, backend: 'catty', contextWindow: 1_300_000, trigger: 'pre-turn' });
const step = await prepareStepContext({ messages: turn.messages, contextWindow: 1_300_000, stepNumber: 0 });
for (const prepared of [turn, step]) {
assert.deepEqual(prepared.messages.find(message => message.role === 'user')?.content, userContent);
assert.ok(JSON.stringify(prepared.messages).length < JSON.stringify(messages).length);
}
}
});
test('prepareTurnContext applies typed compression before LLM summarize threshold', async () => {
const longOutput = 'line\n'.repeat(20_000);
const messages: ModelMessage[] = [
@@ -1,7 +1,6 @@
import type { ChatMessage } from '../types';
import type { ExternalBridgeHistoryMessage } from './types';
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from '../../../components/ai/cattyHistoryReplay';
import { formatVaultNoteReferences, isVaultNoteAttachment } from '../../../application/state/vaultNoteAttachment';
type ExternalAgentHistoryMessage = ExternalBridgeHistoryMessage;
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
@@ -174,13 +173,6 @@ function summarizeMessage(
function summarizeDurableUserMessage(message: ChatMessage): string | null {
if (message.role !== "user") return null;
const notes = message.attachments?.filter(isVaultNoteAttachment) ?? [];
if (notes.length) {
// Budget prose separately: the reference instruction must not consume the request's 280 characters.
const nonNotes = message.attachments?.filter((attachment) => !isVaultNoteAttachment(attachment));
const request = truncateText(normalizeWhitespace(buildHistoricalUserReplayContent(message.content || '', nonNotes)), MAX_DURABLE_USER_MESSAGE_CHARS);
return `User request: ${request}\n${formatVaultNoteReferences(notes)}`;
}
const content = getUserHistoryContent(message);
if (!content) return null;
if (isTrivialUserMessage(content)) return null;
@@ -450,4 +442,4 @@ export function buildExternalBridgeContextMessages(messages: ChatMessage[]): Ext
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
return [...compactContext, ...recentRaw];
}
}
@@ -687,7 +687,7 @@
"os": {
"type": "string",
"optional": true,
"description": "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate."
"description": "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior."
},
"identityId": {
"type": "string",
@@ -894,32 +894,12 @@
"toolName": "vault_notes_get",
"rpcMethod": "vault/notes/get",
"localExecution": false,
"description": "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
"description": "Get a Vault → Notes entry by id (full markdown content).",
"inputShape": {
"noteId": {
"type": "string",
"optional": false,
"description": "Vault note ID from vault_notes_list."
},
"offset": {
"type": "number",
"optional": true,
"description": "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset."
},
"maxChars": {
"type": "number",
"optional": true,
"description": "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000."
},
"query": {
"type": "string",
"optional": true,
"description": "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search."
},
"expectedUpdatedAt": {
"type": "number",
"optional": true,
"description": "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed."
}
},
"policy": {
@@ -733,7 +733,7 @@
"os": {
"type": "string",
"optional": true,
"description": "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate."
"description": "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior."
},
"identityId": {
"type": "string",
@@ -946,32 +946,12 @@
"toolName": "vault_notes_get",
"rpcMethod": "vault/notes/get",
"localExecution": false,
"description": "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
"description": "Get a Vault → Notes entry by id (full markdown content).",
"inputShape": {
"noteId": {
"type": "string",
"optional": false,
"description": "Vault note ID from vault_notes_list."
},
"offset": {
"type": "number",
"optional": true,
"description": "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset."
},
"maxChars": {
"type": "number",
"optional": true,
"description": "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000."
},
"query": {
"type": "string",
"optional": true,
"description": "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search."
},
"expectedUpdatedAt": {
"type": "number",
"optional": true,
"description": "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed."
}
},
"policy": {
@@ -13,7 +13,7 @@ import {
} from '../../../../components/ai/cattyHistoryReplay';
import {
buildPromptWithTerminalSelectionAttachments,
isInlineTextAttachment,
isTerminalSelectionAttachment,
} from '../../../../application/state/terminalSelectionAttachment';
import {
getOpenAIChatAssistantFieldsForHistoryMessage,
@@ -411,7 +411,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
if (attachments?.length) {
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter(
(attachment) => !isInlineTextAttachment(attachment),
(attachment) => !isTerminalSelectionAttachment(attachment),
);
if (!modelAttachments.length) {
sdkMessages.push({ role: 'user', content: modelText });
@@ -124,9 +124,6 @@ export function compressMessagesForRequestTooLargeRetry(
function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessage {
if (typeof message.content === "string") {
// User text is an instruction or exact input, not terminal output. Lossy
// cleanup can alter commands even when the request is far below its limit.
if (message.role === "user") return message;
const content = compressAndTruncateText(message.content, RETRY_MAX_MESSAGE_TEXT_CHARS);
return content === message.content ? message : ({ ...message, content } as ModelMessage);
}
@@ -135,7 +132,6 @@ function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessag
let didAdjust = false;
const content = message.content.map((part) => {
if (message.role === "user" && part.type === "text") return part;
const compressed = compressContentPartForRequestRetry(part);
if (compressed !== part) didAdjust = true;
return compressed;
-8
View File
@@ -105,10 +105,6 @@ export interface ChatMessageAttachment {
filename?: string;
filePath?: string; // original filesystem path, when available
terminalSelection?: boolean;
/** Set when the attachment was created from a Vault → Notes mention. */
vaultNoteId?: string;
/** Original note title, for display and prompt labels. */
vaultNoteTitle?: string;
previewText?: string;
lineCount?: number;
}
@@ -121,10 +117,6 @@ export interface UploadedFile {
mediaType: string;
filePath?: string;
terminalSelection?: boolean;
/** Set when the attachment was created from a Vault → Notes mention. */
vaultNoteId?: string;
/** Original note title, for display and prompt labels. */
vaultNoteTitle?: string;
previewText?: string;
lineCount?: number;
}
@@ -1,7 +1,5 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fitLargeToolResultForModel } from './harness/toolResultFitting';
import type { GroupConfig, Host, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, VaultNote } from '../../domain/models';
import { handleVaultAgentOp, runSerializedVaultAgentRequest, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
@@ -139,46 +137,6 @@ function createDeps(
}
describe('handleVaultAgentOp vault notes', () => {
it('bounds both Catty and MCP reads through the real service and forwards continuation/search parameters', async () => {
const require = createRequire(import.meta.url);
const { createVaultService } = require('../../electron/capabilities/services/vaultService.cjs');
const { registerMcpTools } = require('../../electron/capabilities/codegen/mcpToolRegistry.cjs');
const content = Array.from({ length: 10_000 }, (_, i) => `line ${i}: example text\n`).join('');
const deps = createDeps({ notes: [{ id: 'note-1', title: 'Long', content, createdAt: 1, updatedAt: 2 }] });
const service = createVaultService({ invokeVaultAgent: (op: string, params: Record<string, unknown>) => handleVaultAgentOp(op, params, deps) });
type Result = { note: { content: string }; nextOffset: number | null; matchOffset?: number };
const handlers = new Map<string, (params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>>();
registerMcpTools({ tool: (...args: unknown[]) => handlers.set(args[0] as string, args.at(-1) as NonNullable<ReturnType<typeof handlers.get>>) }, {
rpcCall: (_method: string, params: Record<string, unknown>) => service.getNote(params),
scopeParams: {}, guardWriteOperation: () => null, catalogDescription: (_name: string, description: string) => description,
});
const get = handlers.get('vault_notes_get')!;
let offset: number | null = 0;
let collected = '';
while (offset !== null) {
const params = { noteId: 'note-1', offset, maxChars: 9000, expectedUpdatedAt: 2 };
const raw = await service.getNote(params);
const catty = fitLargeToolResultForModel({ result: raw, capabilityId: 'vault.note.get' });
const mcp = JSON.parse((await get(params)).content[0].text) as Result;
assert.deepEqual(catty, mcp);
assert.ok(mcp.note.content.length <= 6000);
collected += mcp.note.content;
offset = mcp.nextOffset;
}
assert.equal(collected, content);
const found = JSON.parse((await get({ noteId: 'note-1', query: 'line 9999:', maxChars: 20 })).content[0].text) as Result;
assert.equal(found.matchOffset, content.indexOf('line 9999:'));
assert.equal(found.note.content.length, 20);
assert.equal((await service.getNote({ noteId: 'note-1', expectedUpdatedAt: 1 })).ok, false);
assert.equal((await service.getNote({ noteId: 'missing' })).ok, false);
const importedId = 'x'.repeat(201);
deps.updateNotes([{ id: importedId, title: 'Imported', content, createdAt: 1, updatedAt: 2 }]);
const imported = await service.getNote({ noteId: importedId });
assert.equal(imported.ok, true);
assert.equal(imported.note.id, importedId);
assert.equal(imported.note.content.length, 6000);
});
it('note.create persists to updateNotes and returns the new note', async () => {
const updated: VaultNote[][] = [];
const deps = createDeps({
+3 -6
View File
@@ -1,4 +1,3 @@
import { resolveHostOs } from '../../domain/host';
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
import {
@@ -25,7 +24,6 @@ import {
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { applySnippetVariables, parseSnippetVariables } from '../../domain/snippetVariables';
import { getNextVaultOrder } from '../../domain/vaultOrder';
import { readVaultNote } from '../../domain/vaultNoteRead';
import {
runAutomationScript,
stopScriptRun,
@@ -128,7 +126,6 @@ export function sanitizeHostForAgent(host: Host): Record<string, unknown> {
}
sanitized[key] = value;
}
sanitized.os = resolveHostOs(host);
return sanitized;
}
@@ -142,7 +139,7 @@ function summarizeHostForList(host: Host) {
protocol: host.protocol,
group: host.group,
tags: host.tags,
os: resolveHostOs(host),
os: host.os,
createdAt: host.createdAt,
connectScriptIds: host.connectScriptIds,
loginScriptId: host.loginScriptId,
@@ -533,7 +530,7 @@ async function registerOpenedSessionInMcpScope(
hostId: host.id,
hostname: host.hostname || '',
label: host.label || host.hostname || sessionId,
os: resolveHostOs(host),
os: host.os || '',
username: host.username || '',
protocol,
deviceType: host.deviceType || '',
@@ -934,7 +931,7 @@ export async function handleVaultAgentOp(
const noteId = String(params.noteId || '');
const note = deps.getNotes().find((entry) => entry.id === noteId);
if (!note) return { ok: false, error: `Vault note "${noteId}" was not found.` };
return readVaultNote(note, params);
return { ok: true, note: serializeVaultNoteForAgent(note) };
}
case 'note.create': {
const title = sanitizeNoteTitle(params.title);
@@ -1,104 +0,0 @@
/* global __dirname, process, setTimeout, clearTimeout, console */
// Opt-in real Claude Code permission check; all model traffic stays on loopback.
// Usage: node scripts/fixtures/research-permission-probe.cjs [claude executable]
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { prepareAiCliSettings } = require('../ai-automation.cjs');
const repository = path.resolve(__dirname, '../..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-research-permission-'));
const settings = path.join(root, 'settings.json');
prepareAiCliSettings({ configPath: settings, denyWeb: true, allowBrave: true, allowWrites: false });
for (const name of ['web-search', 'web-fetch', 'unrelated-helper']) {
fs.writeFileSync(path.join(root, name), '#!/bin/sh\nprintf RESEARCH_FIXTURE_OK\n', { mode: 0o755 });
}
const workflow = fs.readFileSync(path.join(repository, '.github/workflows/ai-automation.yml'), 'utf8');
const blocks = [...workflow.matchAll(/--allowedTools "Read" "Bash\(web-search \*\)"([\s\S]*?)--disallowedTools/g)];
assert.equal(blocks.length, 2, 'classification and follow-up research must both be checked');
let command;
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', data => { raw += data; });
req.on('end', () => {
const body = JSON.parse(raw || '{}');
if (req.url.includes('count_tokens')) {
res.setHeader('Content-Type', 'application/json');
res.end('{"input_tokens":10}');
return;
}
const finished = (body.messages || []).some(message => Array.isArray(message.content)
&& message.content.some(item => item.type === 'tool_result'));
const block = finished ? { type: 'text', text: 'DONE' }
: { type: 'tool_use', id: 'tool_fixture', name: 'Bash', input: { command } };
const message = { id: 'msg_fixture', type: 'message', role: 'assistant', model: body.model,
content: [block], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null,
usage: { input_tokens: 20, output_tokens: 10 } };
if (!body.stream) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(message));
return;
}
res.setHeader('Content-Type', 'text/event-stream');
const emit = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
emit('message_start', { message: { ...message, content: [], stop_reason: null } });
emit('content_block_start', { index: 0, content_block: finished
? { type: 'text', text: '' } : { ...block, input: {} } });
emit('content_block_delta', { index: 0, delta: finished
? { type: 'text_delta', text: 'DONE' }
: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
emit('content_block_stop', { index: 0 });
emit('message_delta', { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 10 } });
emit('message_stop', {});
res.end();
});
});
async function check(index, helper, expectRun, baseline = false) {
const allowed = [...blocks[index][0].split('--disallowedTools')[0].matchAll(/"([^"]+)"/g)]
.map(match => match[1].replaceAll('$research_dir', root))
.filter(rule => !baseline || !rule.includes(root));
command = `${path.join(root, helper)} smoke 2>&1 | head -40`;
const args = ['--bare', '-p', '--permission-mode', 'dontAsk', '--settings', settings,
'--allowedTools', ...allowed, '--disallowedTools', 'WebSearch', 'WebFetch', 'Edit', 'Write', 'NotebookEdit',
'--output-format', 'stream-json', '--verbose', '--model', 'claude-sonnet-4-6', 'Run the approved helper once.'];
const env = { PATH: `${root}${path.delimiter}${process.env.PATH}`, HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR, CLAUDE_CONFIG_DIR: path.join(root, 'config'),
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`, ANTHROPIC_AUTH_TOKEN: 'synthetic-local-fixture',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
let output = '', errors = '';
const code = await new Promise((resolve, reject) => {
const child = spawn(process.argv[2] || 'claude', args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
const timeout = setTimeout(() => child.kill('SIGTERM'), 20000);
child.stdout.on('data', data => { output += data; });
child.stderr.on('data', data => { errors += data; });
child.once('error', error => { clearTimeout(timeout); reject(error); });
child.once('exit', value => { clearTimeout(timeout); resolve(value); });
});
assert.equal(code, 0, errors);
const events = output.trim().split('\n').map(line => JSON.parse(line));
const result = events.findLast(event => event.type === 'result');
assert.ok(result, 'Claude must finish the model/tool turn');
const ran = events.some(event => Array.isArray(event.message?.content)
&& event.message.content.some(item => item.type === 'tool_result'
&& JSON.stringify(item.content).includes('RESEARCH_FIXTURE_OK') && !item.is_error));
assert.equal(ran, expectRun, `${index}:${helper}:baseline=${baseline}`);
assert.equal((result.permission_denials || []).length, expectRun ? 0 : 1);
console.log(JSON.stringify({ route: index === 0 ? 'classification' : 'follow-up', helper, baseline, ran }));
}
(async () => {
try {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
for (const index of [0, 1]) {
await check(index, 'web-search', false, true);
await check(index, 'web-search', true);
await check(index, 'web-fetch', true);
await check(index, 'unrelated-helper', false);
}
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}
})().catch(error => { console.error(error.message); process.exitCode = 1; });
-111
View File
@@ -1,111 +0,0 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("fit preserves terminal reading position", {
skip: "run with Electron for real xterm viewport coverage",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("fit-scroll")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const source = fs.readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const start = source.indexOf(" const safeFit = (options?: SafeFitOptions) => {");
const end = source.indexOf(" const prevIsResizingRef", start);
if (start < 0 || end < 0) throw new Error("Cannot extract production safeFit");
const safeFitSource = esbuild.transformSync(source.slice(start, end), { loader: "ts" }).code;
const bundle = esbuild.buildSync({
stdin: {
contents: 'export {Terminal} from "@xterm/xterm"; export {alignTerminalViewportScroll, forceSyncRenderAfterResize, createSynchronizedOutputFitScheduler} from "./components/terminal/terminalHelpers";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1000, height: 700,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='background:%23111;color:white'><h3>Issue 3299: resize reading position</h3><div id='terminal'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {Terminal,forceSyncRenderAfterResize,createSynchronizedOutputFitScheduler} = loaded.exports;
const alignTerminalViewportScroll = process.env.NETCATTY_FIT_BASELINE ? ()=>{} : loaded.exports.alignTerminalViewportScroll;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const term = new Terminal({cols:80,rows:25,scrollback:1000,fontSize:14,allowProposedApi:true,smoothScrollDuration:0});
const el = document.getElementById('terminal');
el.style.cssText='width:900px;height:560px'; term.open(el);
const write = data => new Promise(resolve=>term.write(data,resolve));
const ref = current => ({current});
const termRef=ref(term), containerRef=ref(el), isRendererActiveRef=ref(true), lastFittedSizeRef=ref(null);
const autocompleteRepositionRef=ref(null), xtermRuntimeRef=ref(null), pendingWriteSafeFitRef=ref(null);
const synchronizedFitSchedulerRef=ref(createSynchronizedOutputFitScheduler());
let dimensions={cols:80,rows:25};
const fitAddonRef=ref({proposeDimensions:()=>dimensions});
const hasPendingTerminalWrites=()=>false;
const XTERM_PERFORMANCE_CONFIG={resize:{useRAF:false}};
const logger={warn:(message,error)=>{throw error || new Error(message)}};
${safeFitSource}
const safeFitRef=ref(safeFit);
const fit=(cols,rows)=>{dimensions={cols,rows};safeFit({force:true,immediate:true});};
const domRow=()=>term._core._viewport._scrollableElement.getScrollPosition().scrollTop / term._core._renderService.dimensions.css.cell.height;
const check=(expected,label)=>{
assert.equal(term.buffer.active.viewportY,expected,label+' buffer');
assert.ok(Math.abs(domRow()-expected)<0.01,label+' DOM expected '+expected+' actual '+domRow());
};
await write(Array.from({length:200},(_,i)=>'line '+i+' '+('A B repeated '.repeat(7))).join('\\r\\n'));
await wait(100); term.scrollToLine(70); await wait(50);
fit(80,16); await wait(80); check(70,'shrink');
fit(80,30); await wait(80); check(70,'grow');
for(let rows=29;rows>=12;rows--){fit(80,rows);await wait(20);check(70,'drag '+rows);}
term.scrollToBottom();await wait(50);fit(80,8);await wait(80);check(term.buffer.active.baseY,'pinned shrink');
term.scrollLines(-1);await wait(50);check(term.buffer.active.baseY-1,'first scroll after shrink');
term.scrollToLine(70);await wait(50);
const oldSize=[term.cols,term.rows];
await write('\\x1b[?2026h');fit(100,20);fit(120,24);await wait(80);
assert.deepEqual([term.cols,term.rows],oldSize,'no reflow inside synchronized frame');
term.scrollLines(-3);await wait(40);const selected=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(120);
assert.deepEqual([term.cols,term.rows],[120,24]);check(Math.min(selected,term.buffer.active.baseY),'user selected position');
await write('\\x1b[?2026h');fit(90,18);await write('\\x1b[?2026l\\x1b[?2026h');await wait(70);
assert.equal(term.cols,120,'a new synchronized frame delays retry');
await write('\\x1b[?2026l');await wait(100);check(Math.min(selected,term.buffer.active.baseY),'mode reentry');
await write('\\r\\n'+Array.from({length:1200},()=> 'A B repeated').join('\\r\\n'));
await wait(80);term.scrollToLine(100);await wait(30);
await write('\\x1b[?2026h');fit(130,22);await write('\\r\\n'.repeat(5));
const afterTrim=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(100);check(afterTrim,'scrollback trim');
await write('\\x1b[?2026h\\x1b[?1049h');fit(100,20);
await write('alternate output\\x1b[?1049l\\x1b[?2026l');await wait(100);
check(Math.min(afterTrim,term.buffer.active.baseY),'alternate buffer round trip');
await write('\\x1b[?2026h');fit(110,21);await wait(1400);
assert.equal(term.modes.synchronizedOutputMode,false,'xterm timeout releases output');
assert.equal(term.cols,110,'fit retried after output timeout');
const beforeDispose=term.cols;
await write('\\x1b[?2026h');fit(140,25);synchronizedFitSchedulerRef.current.dispose();
await write('\\x1b[?2026l');await wait(100);assert.equal(term.cols,beforeDispose,'teardown cancels retry');
assert.ok(el.querySelector('.xterm-screen').getBoundingClientRect().height > 0,'real rendered terminal');
return {passed:['shrink','grow','18 drag steps','bottom and next scroll','synchronized repeated spaced text','user scroll','mode reentry','full scrollback trim','alternate buffer','output timeout','cleanup'],viewportY:term.buffer.active.viewportY,domRow:domRow()};
})()`);
console.log(JSON.stringify(result));
if (process.env.NETCATTY_FIT_SCREENSHOT) {
fs.writeFileSync(process.env.NETCATTY_FIT_SCREENSHOT, (await win.webContents.capturePage()).toPNG());
}
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}