Compare commits

..
84 changed files with 298 additions and 5802 deletions

No files matched your search

+2 -1
View File
@@ -1202,7 +1202,8 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: *freeze_ai_helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
- name: Install Claude Code CLI
run: |
-4
View File
@@ -528,10 +528,6 @@ export const enAiMessages: Messages = {
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.searchSessions': 'Search titles and recent content…',
'ai.chat.clearSessionSearch': 'Clear session search',
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
-4
View File
@@ -519,10 +519,6 @@ export const esAiMessages: Messages = {
'ai.chat.jumpUntitled': '(mensaje vacío)',
'ai.chat.usedTools': 'Herramientas usadas: {n}',
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
-4
View File
@@ -475,10 +475,6 @@ export const ruAiMessages: Messages = {
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
-4
View File
@@ -528,10 +528,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.searchSessions': '搜索标题和近期内容…',
'ai.chat.clearSessionSearch': '清空会话搜索',
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
-4
View File
@@ -518,10 +518,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.searchSessions': '搜尋標題和近期內容…',
'ai.chat.clearSessionSearch': '清空會話搜尋',
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
-109
View File
@@ -466,115 +466,6 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
}
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
// Just over the 8 KiB cap: the truncation marker must not make the degraded
// copy larger than the original, or the ladder could evict a session whose
// unmodified JSON would have fit the budget.
for (const oversize of [8193, 8266, 20000]) {
const session = makeSession('near-cap', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
}]);
const fullJson = JSON.stringify([session]);
const result = serializeSessionsForStorage([session], fullJson.length - 10);
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
assert.ok(
JSON.stringify(result.sessions).length < fullJson.length,
`degraded copy smaller than original for ${oversize}`,
);
assert.ok(content.includes('truncated'));
}
});
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'still-open chat',
timestamp: 2,
}]);
const toolBulk = makeSession('tool-bulk', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
}]);
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
// The bloated tool result must not evict the other visible chat.
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
assert.ok(result.json.length <= 100 * 1024);
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
assert.ok(persistedToolContent);
assert.ok(persistedToolContent.length <= 9 * 1024);
assert.ok(persistedToolContent.includes('truncated'));
assert.ok(persistedToolContent.startsWith('zzz'));
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
});
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'tool' as const,
content: '',
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(
persisted.map(session => session.id),
sessions.map(session => session.id),
);
// Degradation sheds only as many messages as the budget demands, so at
// least one chat keeps its full tool output while the persisted set stays
// complete. Assert completion first; truncation second.
const shedContents = persisted.map(
session => session.messages[0].toolResults?.[0].content ?? '',
);
assert.ok(shedContents.some(content => content.includes('truncated')));
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
+29 -79
View File
@@ -195,40 +195,12 @@ const RETRY_SESSIONS_JSON_BYTES = [
128 * 1024,
] as const;
/**
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
@@ -246,7 +218,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -267,22 +239,6 @@ function stripReasoningCiphertextFromMessage(message: AISession['messages'][numb
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
@@ -303,7 +259,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -354,11 +310,6 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
@@ -370,13 +321,13 @@ export function serializeSessionsForStorage(
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -388,7 +339,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
ciphertextMessages,
};
});
@@ -413,12 +364,11 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
@@ -428,27 +378,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
@@ -8,20 +8,10 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events close the session tab only when auto-close is enabled", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
{ kind: "closeSession" },
);
});
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
// The fallback for callers that have not loaded the terminal setting yet:
// keep the tab so a jump host / bastion reaping a session (a clean exit)
// never silently deletes the user's scrollback.
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -67,19 +57,11 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -151,17 +133,6 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -179,25 +150,3 @@ test("exit messages without a reported code stay plain instead of showing a sent
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
// A terminal-worker exit broadcast carries the worker's own exit status
// (graceful drain = 0). It belongs to the infrastructure failure, not to
// any remote shell, so it must never read as "session closed (code 0)".
assert.equal(
formatSessionClosedExitMessage({
reason: "error",
exitCode: 0,
error: "Terminal worker exited with code 0",
}),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error" }),
"\r\n[session closed unexpectedly]",
);
});
@@ -4,10 +4,7 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -19,13 +16,6 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
// Infrastructure exit codes do not describe the remote shell's status.
if (evt?.reason === "error") {
return "\r\n[session closed unexpectedly]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -44,11 +34,7 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
// Keep-tab is the safe default: a caller that has not loaded the terminal
// setting yet must not silently delete a session tab. Remote graceful
// channel closes (e.g. a jump host reaping an idle session) are reported
// as a clean exit, so an unavailable setting must not re-enable closing.
autoCloseOnExit = false,
autoCloseOnExit = true,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -1,790 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectSessionSearchFields,
type SessionHistorySearchMessage,
} from "../../domain/sessionHistorySearch.ts";
import {
PINYIN_CACHE_MAX_CHARS,
getPinyinCacheStatsForTests,
resetPinyinCacheForTests,
} from "../../lib/searchMatcher.ts";
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
// One instance for the whole file: it keeps the pre-factory semantics of every
// test sharing (and clearing) a single cache.
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
// Fixture messages stay loosely typed: persisted sessions carry far richer
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
// and extra properties must remain structurally harmless.
function createSession(
id: string,
title: string,
messages: Array<Record<string, unknown>>,
) {
return {
id,
title,
messages: messages.map((message, index) => ({
id: `${id}-m${index}`,
role: "user",
content: "",
timestamp: index,
...message,
})) as SessionHistorySearchMessage[],
};
}
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
const sessions = [
createSession("a", "nginx restart", []),
createSession("b", "disk cleanup", []),
];
for (const query of ["", " \t\n"]) {
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
}
});
test("filterSessionHistory handles special characters through the shared matcher", () => {
const sessions = [
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
createSession("b", "Deploy", [{ content: "ship it" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
});
test("filterSessionHistory matches message content of user and assistant turns", () => {
const sessions = [
createSession("a", "Debugging", [
{ role: "user", content: "why is sshd down on prod-3?" },
{ role: "assistant", content: "let me check the journal" },
]),
createSession("b", "Deploy notes", [
{ role: "user", content: "ship release 1.2" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
});
test("filterSessionHistory ignores hidden system messages", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "all clear" },
]),
createSession("b", "Deploy", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "ship it" },
]),
];
// A query found only inside system messages must not surface the sessions:
// the reopened conversation hides those messages, so a match would display
// no matching text.
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
});
test("filterSessionHistory searches thinking and tool call/result content", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", thinking: "the certificate expired" },
]),
createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
});
test("filterSessionHistory matches text stored only in tool call arguments", () => {
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx" },
}],
},
]),
createSession("b", "Deploy", [
{
role: "assistant",
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
});
test("filterSessionHistory matches persisted agent activities", () => {
const sessions = [
createSession("a", "Research", [
{
role: "assistant",
agentActivities: [
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
],
},
]),
createSession("b", "Refactor", [
{
role: "assistant",
agentActivities: [
{
id: "2",
type: "file_change",
status: "completed",
changes: [
{ path: "src/sessionStore.ts", kind: "update" },
{ path: "src/sessionStore.test.ts", kind: "add" },
],
},
],
},
]),
createSession("c", "Plan", [
{
role: "assistant",
agentActivities: [
{
id: "3",
type: "plan_update",
status: "completed",
items: [
{ text: "extract search collector", completed: true },
{ text: "wire into drawer", completed: false },
],
},
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[2]);
assert.ok(fields.some((field) => field.includes("extract search collector")));
assert.ok(fields.some((field) => field.includes("rate limit hit")));
});
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
const sessions = [
createSession("a", "Untitled", [
{
role: "assistant",
content: "",
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
},
]),
createSession("b", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
],
},
]),
createSession("c", "Legacy", [
{
role: "user",
content: "see attached",
images: [
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
],
},
]),
createSession("d", "Payloads", [
{
role: "user",
content: "",
attachments: [
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
// Attachment base64 payloads and unlabeled attachments are not indexed.
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[1]);
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
assert.ok(!fields.some((field) => field === "AAAA"));
});
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
// One assistant message bearing enough tool calls (each argument capped at
// 2,000 chars) to exhaust the remaining session budget: iteration must be
// newest-first so the latest calls (appended at the array end) stay
// searchable instead of the oldest ones winning the budget.
const session = createSession("a", "Ops", [
{
role: "assistant",
content: "y".repeat(20_000),
toolCalls: Array.from({ length: 25 }, (_, i) => ({
name: `call-${i}`,
id: `t${i}`,
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
})),
},
]);
const fields = collectSessionSearchFields(session);
// The newest call (index 24, appended last) stays searchable along with the
// rest of the recent burst; the oldest call is the one dropped.
assert.ok(fields.some((field) => field.includes("marker-args-24")));
assert.ok(fields.some((field) => field.includes("marker-args-19")));
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
// Retained call fields are output in chronological order after the
// newest-first collection is reversed back (call-10 before call-19).
const call10 = fields.findIndex((field) => field.includes("call-10"));
const call19 = fields.findIndex((field) => field.includes("call-19"));
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
});
test("later attachment labels stay searchable when the label list overflows the cap", () => {
// The joined labels of these attachments exceed the 2,000-character field
// cap; feeding labels individually keeps the later (newest) ones findable.
const session = createSession("a", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
],
},
]);
const fields = collectSessionSearchFields(session);
assert.ok(fields.some((field) => field.includes("latest-run.log")));
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
});
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
const session = createSession("a", " ", [
{ role: "user", content: " " },
{ role: "assistant", content: "x".repeat(25_000) },
]);
const fields = collectSessionSearchFields(session);
assert.equal(fields.length, 1);
assert.equal(fields[0].length, 20_000);
});
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
const sessions = [
createSession("a", "重启生产服务器", []),
createSession("b", "backup", []),
];
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
});
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
// An empty persisted title is displayed as the localized "Untitled" label;
// searching for that label must find the session instead of hiding it.
const sessions = [
createSession("a", "", [{ content: "nginx notes" }]),
createSession("b", "nginx restart", []),
];
// Without the fallback the blank title contributes nothing.
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
// Titled sessions are unaffected by the fallback label.
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
// Localized (CJK) fallback labels match through title pinyin too.
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
const fields = collectSessionSearchFields(sessions[0], "Untitled");
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
});
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
const sessions = [
createSession("a", "重启服务器", [
{ role: "user", content: "restarted nginx without errors" },
]),
];
const combined = filterSessionHistory(sessions, "chongqi nginx");
assert.deepEqual(combined, [sessions[0]]);
// Message-only pinyin still stays out of the expensive fallback.
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(
filterSessionHistory(sessions, "failing nginx"),
[],
);
});
test("search field trimming stays inside the field and remaining session limits", () => {
const fieldLimited = createSession("a", "Ops", [
{ content: " ".repeat(20_000) + "outside-field" },
{ content: " recent-match " + " ".repeat(1_000_000) },
]);
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
// Title + three full fields leave 3,997 characters for the oldest message.
// Trimming must not reach past that window to discover more content.
const sessionLimited = createSession("b", "Ops", [
{ content: " ".repeat(3_997) + "outside-session" },
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
]);
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
});
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "user", content: "重启生产服务器" },
]),
];
// Pinyin transliteration of long message content is skipped; literal CJK
// queries still match message text.
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
});
test("collectSessionSearchFields bounds the total searchable text per session", () => {
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
role: "user" as const,
content: `chunk-${i}-${"y".repeat(19_000)}`,
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
// Title is indexed first so it always lands in the bounded window.
assert.equal(fields[0], "big session");
});
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
// allocated newest-first so the most recent message remains searchable.
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
role: "user" as const,
content: `${"x".repeat(19_900)} marker-${i}`,
})));
const fields = collectSessionSearchFields(session);
assert.equal(fields[0], "big session");
// Oldest messages are dropped, the newest is retained.
assert.ok(!fields.some((field) => field.includes("marker-0")));
assert.ok(fields.some((field) => field.includes("marker-3")));
// Output order stays chronological (marker-1 before marker-2).
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
});
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
// collected (oldest) message must be truncated to exactly the remaining
// budget instead of being discarded wholesale.
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
role: "user" as const,
content: "a".repeat(19_000),
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
assert.equal(total, 64_000);
// The oldest message fills the leftover budget with its freshest-available
// head instead of being dropped entirely.
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
assert.ok(fields.some((field) => field.startsWith("aaa")));
});
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
const blob = "x".repeat(5_000_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
// content); serialization must stop at the field cap instead of
// stringifying the whole argument object on every keystroke.
name: "sftp_write_file",
id: "t1",
arguments: { path: "/tmp/report.md", content: blob },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
assert.ok(serialized);
// The serialized field (plus the tool name) must land under the total
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
assert.ok(serialized!.length <= 2_000);
assert.ok(serialized!.includes("/tmp/report.md"));
// Short payloads still serialize fully and stay searchable.
const searchable = createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t2",
arguments: { command: "systemctl restart nginx" },
}],
},
]);
assert.ok(
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
);
});
test("wide tool call argument objects and long property names stay within the cap", () => {
// A wide object: thousands of own properties must be iterated lazily
// instead of materializing the full entries array on every keystroke.
const wide: Record<string, unknown> = {};
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
// An unusually large property name must be capped before it is escaped.
const longKey = "k".repeat(50_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
assert.ok(serialized);
assert.ok(serialized!.length <= 2_000);
// The oversized key (and the wide object past the cap) never materialize:
// the long key is truncated before escaping.
assert.ok(!fields.some((field) => field.length > 2_000));
});
test("filterSessionHistory matches persisted status text", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
]),
createSession("b", "Deploy", [
{ role: "user", content: "ship it" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
});
// Regression: bounds enforcement must run once per scan, not on every cache
// miss. Evicting per miss makes each rebuild drop the index of a later
// session in the same scan, so any history longer than the entry cap
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
const sessionCount = 80;
const sessions = Array.from({ length: sessionCount }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
// Observe corpus (re)builds: a cache hit serves the prepared haystack
// without touching `messages`, while a rebuild collects the fields again.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const second = filterSessionHistory(sessions, "deploy");
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
// every session retained by the last prune are cache hits. Per-miss
// eviction would have rebuilt all 80 sessions here.
const rebuilt = readSessions.size;
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
assert.deepEqual(second, sessions);
// Eviction must retain the FRONT of the ranked list (the sessions the next
// scan visits first) and rebuild only the oldest tail above the entry cap —
// evicting the Map's first key would drop the head and thrash it instead.
const rebuiltIds = [...readSessions].sort();
assert.deepEqual(
rebuiltIds,
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
);
});
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
// live sessions froze the cache's membership. New sessions are prepended to
// the ranked list, so every subsequently created conversation (and its
// immutable streaming updates) would rebuild its index on every keystroke
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
// could not correct it because the cache was already within both bounds.
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the thrash regression test above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted; the prune evicts the lowest-ranked tail instead.
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const third = filterSessionHistory(sessions, "deploy");
assert.deepEqual(third, sessions);
// The fresh, highest-ranked session stays cached; only the evicted tail
// above the entry cap is rebuilt.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: the entry cap must be enforced at cache-admission time, not only
// by the end-of-scan prune. When misses are small (empty/short searchable
// content), the char budget consumes almost no headroom per insert, so
// overshooting the entry cap mid-scan and letting the prune trim it made a
// single keystroke over a very long history build and transiently retain one
// index + Map entry per live session. Admission must instead evict the stale
// lowest-ranked untouched tail to make room (the same entry the prune would
// otherwise drop moments later), keeping the cache within the entry cap
// throughout the scan.
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// The single scan over the prepended list must admit "fresh" by evicting
// the stalest cached tail entry ("s63") during the scan: under the old
// overshoot-then-prune behavior "s63" would stay cached until the prune,
// so it would not be rebuilt in this same scan at all. Rebuilding it here
// is the (bounded, one-entry) cost of never exceeding the entry cap
// mid-scan.
filterSessionHistory(sessions, "deploy");
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
// Recovery is unchanged: the fresh session stays cached, and only the
// tail displaced below the entry cap rebuilds on the next keystroke.
readSessions.clear();
const next = filterSessionHistory(sessions, "deploy");
assert.deepEqual(next, sessions);
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
// 64,000-character corpus cap) already consuming nearly the 4M-character
// cache budget, a newly prepended high-ranked session was rejected without
// evicting anything, and the end-of-scan prune — seeing a cache within both
// bounds — kept the stale lower-ranked entries: the new large conversation
// then rebuilt its index on every search keystroke.
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
// index retaining ~192K chars (normalized fields + haystack + compact
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
const createLargeSession = (id: string) =>
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
filterSessionHistory(initial, "x");
// A newly created large session is prepended: it ranks above every cached
// entry, and its index can only fit if the stale tail is evicted.
const created = createLargeSession("fresh");
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted by evicting the stalest cached tail entry.
filterSessionHistory(sessions, "x");
readSessions.clear();
const second = filterSessionHistory(sessions, "x");
assert.deepEqual(second, sessions);
// The fresh, highest-ranked session stays cached; only the evicted stalest
// tail session (which no longer fits the char budget) rebuilds.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s19"]);
// And the steady state holds on subsequent scans too.
readSessions.clear();
filterSessionHistory(sessions, "x");
assert.deepEqual([...readSessions], ["s19"]);
});
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
filterSessionHistory([kept, removed], "nginx");
// Deleting a session then clearing the query goes through the blank path,
// which must prune the cache against the remaining list.
filterSessionHistory([kept], "");
// Subsequent searches are unaffected: the surviving session stays cached
// (its cached index is still there) and matching still works.
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
// The removed session's index is gone entirely (no match via a stale cache).
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
});
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
filterSessionHistory([session], "nginx");
clearSessionHistorySearchCache();
// After the clear, the next search rebuilds from scratch and still matches.
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
assert.deepEqual(filterSessionHistory([session], ""), [session]);
});
test("independent search instances own isolated caches", () => {
const first = createSessionHistorySearch();
const second = createSessionHistorySearch();
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
// Clearing one instance must not disturb the other's state: both stay fully
// functional afterwards (each store is private to its instance).
second.clearSessionHistorySearchCache();
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
});
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
// A pasted multi-megabyte query must not be re-normalized per session: it is
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
// input and normalized once per scan, so matching falls back to the capped
// prefix (which still spans tokens here).
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
// The uncapped-looking but normal query still matches exactly.
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
});
// Keep the author's long-title rescan protection local to history. Shared
// search must still match complete fields, including later joined fields.
test("history pinyin cache preserves long titles across same-order rescans", () => {
resetPinyinCacheForTests();
const sessions = Array.from({ length: 17 }, (_, index) =>
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
for (let pass = 0; pass < 2; pass += 1) {
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
}
const stats = getPinyinCacheStatsForTests();
assert.equal(stats.size, sessions.length);
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
resetPinyinCacheForTests();
});
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
const sessions = [
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
});
-382
View File
@@ -1,382 +0,0 @@
import {
getSessionSearchTitle,
matchesSessionSearchIndex,
prepareSessionSearchIndex,
type SessionHistorySearchTarget,
type SessionSearchIndex,
} from '../../domain/sessionHistorySearch';
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
/**
* Memory bounds for the per-session search index cache. `filterSessionHistory`
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
* haystack per keystroke scans tens of megabytes and freezes the renderer.
*
* The raw collected fields are NOT retained (only the prepared search fields),
* and
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
* unbounded cache would let one entry per live session (several normalized
* copies of each session's searchable corpus) permanently pin hundreds of
* megabytes in a long-running process. Both bounds hold after every scan's
* prune and are enforced eagerly on every insert (see
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
* in-flight scan has not yet visited — they rank below every visited session,
* so this can never cycle-evict an entry the same scan still needs); if no
* such entry remains, the insert is skipped — the miss is then rebuilt on the
* next search, exactly what the end-of-scan prune would have done to it.
* Skipping is only reachable for misses ranked below every cached entry, so
* cache membership never freezes for freshly prepended high-ranked sessions:
* at their (earliest) visit nothing is touched yet and the stale tail yields.
*/
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
/**
* Mutable cache state owned by exactly one search client (one mounted history
* drawer). Keeping this out of module scope means several concurrently mounted
* drawers (one per terminal tab) never share a cache: each drawer's prune only
* drops entries no longer in that drawer's scoped session list, and one
* drawer's unmount releases only its own store (previously a shared
* module-level cache was discarded for every drawer on any unmount and pruned
* against whichever drawer scanned last).
*/
export type SessionHistorySearchStore = {
/**
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
* object identity is a reliable cache key: each session's normalized/compact
* haystacks are built once and reused until that session's object changes.
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
*/
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
/**
* Session keys the in-flight `filterSessionHistory` scan has already visited
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
* never evicts a touched key: it either ranks above the current insert (it was
* visited earlier in this newest-first scan) or it is the current session
* itself (whose stale entry a rebuild is about to replace), and evicting either
* would cycle-evict entries the same scan still needs.
*/
scanTouched: Set<SessionHistorySearchTarget>;
/**
* Running total of the cache's retained characters. Maintained at every
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
* O(1) per miss instead of re-summing the whole cache on every keystroke
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
* across a scan).
*/
cacheChars: number;
};
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
function indexRetainedChars(index: SessionSearchIndex): number {
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
// from `haystack`), so their combined length approximates the cache's
// per-entry memory cost. Counting only the joined haystacks would
// undercount by roughly a full copy of the corpus and let the char budget
// retain ~50% more than advertised.
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
for (const field of index.prepared.normalizedFields) total += field.length;
return total;
}
/**
* Whether a freshly built index may be inserted into the cache without
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
* that re-enforces them runs once per scan, so admitting every prepared index
* would let a single search over hundreds/thousands of uncached sessions
* transiently pin the whole corpus (several normalized haystacks per session)
* or hold one index and Map entry per live session — for empty/short sessions
* the char budget consumes little room, so the entry cap would be enforced
* only after the complete scan — before pruning, briefly allocating memory
* for the whole scanned list.
*
* Rather than rejecting inserts outright (which froze cache membership the
* same way a hard entry-count gate would), lack of headroom in either bound
* first evicts the cache's stale tail — the lowest-ranked entries this scan
* has not yet visited. Because the scan visits the ranked list newest-first,
* every untouched entry ranks below the current insert, so evicting any of
* them never drops an index a later visit of the same scan still needs (no
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
* if no untouched entry remains does the insert get skipped — that is
* reachable only for misses ranked below every cached entry, which the
* end-of-scan prune would have evicted from the Map's back anyway, so the
* skip changes no steady-state membership: the index is simply rebuilt on
* the next search.
*
* The bounds check is applied to NEW entries only: cache hits merely
* re-append an existing entry (no growth), and a rebuild replaces the same
* session's entry (no entry-count growth; the char delta against the
* replaced index is checked).
*/
function canCacheSessionSearchIndex(
store: SessionHistorySearchStore,
previous: SessionSearchIndex | undefined,
index: SessionSearchIndex,
): boolean {
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
// A rebuild replaces its own entry (no entry-count growth); a new entry
// grows the Map by one.
const growsEntries = !previous;
if (
deltaChars <= 0
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
if (
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
// the Map's order is old-insertion-order front to back with this scan's
// touched keys re-appended at the end, so scanning the keys back-to-front
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
// entries first.
let entriesToFree = growsEntries
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
: 0;
const keysInInsertionOrder = [...store.cache.keys()];
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
if (entriesToFree <= 0
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
break;
}
const key = keysInInsertionOrder[i];
if (store.scanTouched.has(key)) continue;
const evicted = store.cache.get(key);
if (!evicted) continue;
store.cacheChars -= indexRetainedChars(evicted);
store.cache.delete(key);
if (growsEntries) entriesToFree--;
}
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
}
function getSessionSearchIndex(
store: SessionHistorySearchStore,
session: SessionHistorySearchTarget,
untitledLabel: string,
): SessionSearchIndex {
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
const cached = store.cache.get(session);
if (cached && cached.displayTitle === indexedTitle) {
// Re-append on every visit so the cache's insertion order mirrors the
// latest scan's visitation order (the ranked, newest-first session list);
// `pruneSessionSearchIndexCache` relies on that order when evicting.
store.cache.delete(session);
store.cache.set(session, cached);
store.scanTouched.add(session);
return cached;
}
const index = prepareSessionSearchIndex(session, untitledLabel);
// Mark the session touched before the gate so the gate's admission eviction
// can never pick this session's own (stale) entry — the rebuild below is
// about to replace it, and evicting it would corrupt the running char total.
store.scanTouched.add(session);
if (canCacheSessionSearchIndex(store, cached, index)) {
store.cache.set(session, index);
// Keep the eager gate's running total in sync (also covers the
// display-title-change rebuild, which replaces an existing entry).
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
}
return index;
}
/**
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
* on every cache miss: evicting during the scan would make each miss drop the
* index of a later session in the same scan, so any history longer than the
* entry cap would cycle-evict the whole cache and rebuild every session's
* haystack on every keystroke. Deferring eviction keeps every retained entry
* usable within a scan while still bounding the cache — first by dropping
* indexes for sessions no longer in the scanned list, then by evicting the
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
* until both bounds hold, preserving the front of the ranked list.
*
* In practice the eviction loop is cheap: both bounds are enforced eagerly
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
* overshoots the entry cap or char budget mid-scan — the prune only drops
* indexes for sessions that left the scanned list and re-syncs the running
* char total.
*/
function pruneSessionSearchIndexCache(
store: SessionHistorySearchStore,
sessions: readonly SessionHistorySearchTarget[],
): void {
const live = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!live.has(key)) store.cache.delete(key);
}
let retained = 0;
for (const cachedIndex of store.cache.values()) {
retained += indexRetainedChars(cachedIndex);
}
// Re-sync the eager gate's running total: the sum above is authoritative
// here (once per scan), so any drift accumulated mid-scan is corrected.
store.cacheChars = retained;
// Evict from the END of the Map until both bounds hold. Every scan visits
// the full ranked session list newest-first and `getSessionSearchIndex`
// re-appends each visited entry, so after a scan the Map's insertion order
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
// Map order) would therefore drop the newest, highest-ranked sessions and
// retain the stalest tail — every subsequent keystroke would rebuild every
// session above the cache cap. Evicting the back instead preserves the
// front of the ranked list. The single remaining entry is always kept, even
// if it alone exceeds the char budget (a single entry is bounded by ~192K
// chars by the collector caps).
// Snapshot the keys once: prune only deletes (never inserts), so popping
// from this array visits keys exactly in Map order, back to front.
const keysInInsertionOrder = [...store.cache.keys()];
while (
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
) {
// A single entry can never push the cache past the char budget (it is
// bounded by ~192K chars by the collector caps), so keep the last entry.
if (store.cache.size <= 1) break;
const evictKey = keysInInsertionOrder.pop();
if (evictKey === undefined) break;
const evicted = store.cache.get(evictKey);
if (evicted) retained -= indexRetainedChars(evicted);
store.cache.delete(evictKey);
}
store.cacheChars = retained;
}
/**
* Release every cached index and touched-session reference for one store
* (invoked for the drawer's unmount lifecycle via the per-drawer search
* instance). Cache keys are the session objects themselves, so clearing fully
* drops all strong session references — the retained haystack characters
* become garbage together with their sessions.
*/
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
store.cache.clear();
store.scanTouched.clear();
store.cacheChars = 0;
}
export type SessionHistorySearchOptions = {
/**
* Localized fallback label rendered by the drawer for sessions with an
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
* displayed title so searching for the visible label finds the session.
*/
untitledLabel?: string;
};
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
store: SessionHistorySearchStore,
sessions: readonly T[],
query: string,
options: SessionHistorySearchOptions = {},
): T[] {
const trimmed = query.trim();
if (!trimmed) {
// The blank-query path skips the scan entirely; release any stale
// scan-touched state so it cannot pin visited sessions (and their
// message/attachment memory) while the drawer is open without a query.
// Normally this set is already empty — the nonblank scan clears it in its
// `finally` — but clearing here too keeps every entry path leak-free.
store.scanTouched.clear();
// Cache keys are the session objects themselves, so an entry for a
// deleted/replaced session strongly retains that session (and its full
// message/attachment payloads). Prune against the current list here too:
// the blank-query path may run while the drawer stays open after
// deletions, and a later nonblank search that would prune otherwise may
// never happen (e.g. the user just closes the drawer).
pruneSessionSearchIndexCache(store, sessions);
return [...sessions];
}
const untitledLabel = options.untitledLabel ?? '';
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
// query would otherwise be re-normalized (NFKC), tokenized and compacted
// inside `matchesSessionSearchIndex` for every session, freezing the
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
// — so the meaning of over-limit queries stays local to session history and
// the normalized forms are computed a single time, letting the per-session
// work below only do `includes` scans over the cached haystacks.
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
// A new scan starts with no visited sessions: the touched set records only
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
// previous scan's keys for keys the current scan still needs.
store.scanTouched.clear();
try {
// Drop entries still held by sessions that left the scan list (e.g. the
// history shrank or session objects changed since the previous scan). Those
// entries are never visited by this scan — the end-of-scan prune would
// remove them too — but dropping them up front keeps their characters
// available to this scan's inserts (via the eager char gate). Cheap: one
// Set plus one pass over the cached keys.
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!currentLive.has(key)) {
const removed = store.cache.get(key);
if (removed) store.cacheChars -= indexRetainedChars(removed);
store.cache.delete(key);
}
}
// Scan all sessions first (a miss admits its index into the cache —
// evicting this scan's untouched stale tail whenever either bound lacks
// headroom, and skipping the insert only when no untouched entry remains),
// then prune entries that fell out of the scan list at the end of the
// scan.
const result = sessions.filter((session) => {
const index = getSessionSearchIndex(store, session, untitledLabel);
return matchesSessionSearchIndex(preparedQuery, index);
});
// Prune against the full scanned list (not the filtered result): sessions
// that merely did not match this query must keep their cached indexes.
pruneSessionSearchIndexCache(store, sessions);
return result;
} finally {
// The touched set only guards against cycle-eviction within the current
// scan; once the scan ends (even if it throws), release its strong
// references to session objects so deleted/replaced sessions and their
// large message/attachment payloads aren't retained until the next
// nonblank search.
store.scanTouched.clear();
}
}
/**
* One stateful search instance per consumer (one mounted session history
* drawer). The cache lives in the returned instance's store instead of module
* scope, so concurrently mounted drawers (one per terminal tab) never share
* state: an unmount of one drawer clears only that drawer's indexes, and each
* drawer's prune is scoped to its own session list.
*/
export type SessionHistorySearch = {
filterSessionHistory: <T extends SessionHistorySearchTarget>(
sessions: readonly T[],
query: string,
options?: SessionHistorySearchOptions,
) => T[];
clearSessionHistorySearchCache: () => void;
};
export function createSessionHistorySearch(): SessionHistorySearch {
const store: SessionHistorySearchStore = {
cache: new Map(),
scanTouched: new Set(),
cacheChars: 0,
};
return {
filterSessionHistory(sessions, query, options = {}) {
return filterSessionHistoryStore(store, sessions, query, options);
},
clearSessionHistorySearchCache() {
clearSessionHistorySearchStore(store);
},
};
}
@@ -9,14 +9,9 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -145,72 +140,3 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
"--ring": "270 70% 45%",
});
});
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.autoCloseOnExit, false);
// Other saved settings pass through untouched.
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
false,
);
// The user re-enables after upgrading: the migration must not undo it again.
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
true,
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
const store = installMemoryLocalStorage();
try {
// Fresh install: no saved settings at all.
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
// Explicit-off (or any settings blob without the key) is never rewritten.
delete (globalThis as { localStorage?: unknown }).localStorage;
const store2 = installMemoryLocalStorage();
store2.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 500 }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
scrollback: 500,
});
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -29
View File
@@ -1,9 +1,5 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -67,30 +63,6 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
}
return raw;
}
/**
* One-time migration for the autoCloseOnExit default flip (tabs now survive
* session exits by default; the old default-on silently deleted tabs whenever
* a bastion or idle-harvesting server reported a clean exit). Builds before
* the flip shipped autoCloseOnExit: true as the default and materialized it
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
* carry a value they never chose. Leaving it in place would make the new
* default unreachable for them. Reset that materialized `true` exactly once
* and persist the marker; afterwards every stored value is treated as the
* user's own choice and is respected. An inbound cloud or backup payload that
* still carries the pre-migration materialized true (no
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
* before this release must re-enable it once.
*/
export function migrateMaterializedAutoCloseOnExitDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored?.autoCloseOnExit === true) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import { UploadController } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,64 +37,3 @@ test("external upload controls survive their originating panel unmount", async (
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});
for (const selectedFile of ["a.bin", "c.bin"]) {
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const controller = new UploadController();
const cancelled: string[] = [];
const stagedCancelled: string[] = [];
const ids = new Map<string, string>();
const pending = new Map<string, (cancelled?: boolean) => void>();
const started: string[] = [];
const upload = uploadEntriesDirect(
["a.bin", "b.bin", "c.bin"].map((name) => ({
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
})),
{
targetPath: "/remote", sftpId: "fixture", isLocal: false,
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => {
ids.set(task.fileName, task.id);
registerExternalUploadController(task.id, controller, "file");
},
},
bridge: {
mkdirSftp: async () => {},
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
started.push(sourcePath);
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
}),
},
},
controller,
);
t.after(async () => {
await controller.cancel();
for (const finish of pending.values()) finish(true);
await upload;
});
await new Promise(setImmediate);
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
const selectedId = ids.get(selectedFile)!;
await cancelExternalUploadRuntime(selectedId);
assert.deepEqual(cancelled, [selectedId]);
assert.deepEqual(stagedCancelled, [selectedId]);
assert.equal(controller.isCancelled(), false);
await new Promise(setImmediate);
assert.deepEqual(started, selectedFile === "a.bin"
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
: ["/local/a.bin", "/local/b.bin"]);
for (const [id, finish] of pending) if (id !== selectedId) finish();
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
fileName === selectedFile
? { fileName, success: false, cancelled: true }
: { fileName, success: true }
)));
});
}
@@ -5,13 +5,11 @@ import type { UploadController } from "../../../lib/uploadService";
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const scopesByTaskId = new Map<string, "file" | "batch">();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
scope: "file" | "batch" = "batch",
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
@@ -21,7 +19,6 @@ export function registerExternalUploadController(
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
scopesByTaskId.set(taskId, scope);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
@@ -33,7 +30,6 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -44,10 +40,6 @@ export function getExternalUploadController(taskId: string): UploadController |
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
if (taskId && scopesByTaskId.get(taskId) === "file") {
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
return true;
}
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
@@ -58,6 +50,5 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
scopesByTaskId.clear();
taskIdsByController.clear();
}
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
registerUploadController(task.id, controller);
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -58,52 +58,6 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("transport drops still auto reconnect", () => {
for (const evt of [
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
}
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
+1 -10
View File
@@ -40,16 +40,7 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
@@ -1,8 +1,5 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -21,7 +18,6 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -67,44 +63,10 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
for (const savedMinutes of [null, 30]) {
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
const restore = installMemoryLocalStorage();
const manager = createSessionIdleManager();
try {
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
if (savedMinutes !== null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
}
const expected = savedMinutes ?? manager.getTimeoutMinutes();
const received: number[] = [];
const bridge = {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
savedMinutes,
'sync does not migrate or overwrite the stored preference',
);
} finally {
manager.clearAll();
restore();
}
});
}
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
@@ -1,24 +0,0 @@
import { useEffect, useMemo } from 'react';
import {
createSessionHistorySearch,
type SessionHistorySearch,
} from './sessionHistorySearch';
/**
* Owns one search instance per mounted session history drawer.
*
* The search index cache is bound to this instance (not module scope), so
* several concurrently mounted drawers — one per terminal tab — each keep
* their own indexes: pruning in one drawer never drops another drawer's
* entries, and the unmount below releases only this drawer's store instead of
* discarding the shared cache for every drawer. Cache keys are the session
* objects themselves, so clearing on unmount drops all strong session
* references together with the retained haystack characters.
*/
export function useSessionHistorySearch(): SessionHistorySearch {
const search = useMemo(() => createSessionHistorySearch(), []);
useEffect(() => () => {
search.clearSessionHistorySearchCache();
}, [search]);
return search;
}
-4
View File
@@ -151,7 +151,6 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -356,9 +355,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
-44
View File
@@ -1667,50 +1667,6 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
try {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { autoCloseOnExit: true } },
}, { importVaultData: () => {} });
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(resurrected.autoCloseOnExit, false);
assert.equal(resurrected.scrollback, 3000);
// An explicit opt-in made after the migration round-trips with a marker.
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
);
const optedIn = buildSyncPayload(vault());
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload(optedIn, { importVaultData: () => {} });
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restoredChoice.autoCloseOnExit, true);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
}
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
-22
View File
@@ -25,7 +25,6 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -63,7 +62,6 @@ import {
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -507,9 +505,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
settings.autoCloseOnExitDefaultMigrated = true;
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
@@ -800,23 +795,6 @@ async function applySyncableSettings(
? 'disabled'
: 'paste';
}
if ('autoCloseOnExit' in settings.terminalSettings) {
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
settings.terminalSettings.autoCloseOnExit,
settings.autoCloseOnExitDefaultMigrated,
);
}
// A legacy true is the old default. A migrated peer's explicit choice must
// stick, so record the marker before the next local migration pass.
if (
settings.autoCloseOnExitDefaultMigrated === true
|| (
settings.terminalSettings.autoCloseOnExit === true
&& settings.autoCloseOnExitDefaultMigrated !== true
)
) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
@@ -1,126 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
const dom = installDomEnvironment();
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(dom.document);
const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({
id: `s-${i}`,
title: `Session ${i}`,
agentId: "catty",
scope: { type: "terminal", targetId: "terminal-1" },
messages: [{
id: `m-${i}`, role: "user", timestamp: 0,
content: i === 140 ? "needle [prod].json" : "ordinary content",
}],
createdAt: 0,
updatedAt: 0,
}));
const selected: string[] = [];
const deleted: string[] = [];
let closed = 0;
const render = (items = sessions, locale = "en") => renderer.render(
<I18nProvider locale={locale}>
<TooltipProvider>
<SessionHistoryDrawer
sessions={items}
activeSessionId="s-140"
onSelect={(id) => selected.push(id)}
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
onClose={() => { closed += 1; }}
/>
</TooltipProvider>
</I18nProvider>,
);
const rows = () => renderer.container.querySelectorAll('[role="button"]');
const click = async (element: Element | null | undefined) => {
assert.ok(element);
await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true }));
};
const enterQuery = async (query: string) => {
const input = renderer.container.querySelector("input");
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
setValue.call(input, query);
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
};
const loadMore = () => [...renderer.container.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Load more"));
try {
await render();
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await click(loadMore());
assert.equal(rows().length, 140);
await enterQuery("[prod].json");
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
assert.equal(loadMore(), undefined);
await click(rows()[0]);
await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await click(rows()[0].querySelector("button"));
assert.deepEqual(selected, ["s-140", "s-140"]);
assert.deepEqual(deleted, ["s-140"]);
// A pasted suffix must not remain visible while the matcher silently
// ignores it: the controlled input and search accept the same prefix.
const pastedQuery = "needle ".repeat(36) + "needle absent-tail";
await enterQuery(pastedQuery);
const boundedInput = renderer.container.querySelector("input")!;
assert.equal(boundedInput.value, pastedQuery.slice(0, 256));
assert.equal(boundedInput.maxLength, 256);
assert.ok(!boundedInput.value.includes("absent-tail"));
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
await enterQuery("needle absent-tail");
assert.equal(boundedInput.value, "needle absent-tail");
assert.equal(rows().length, 0, "every visible query token must affect matching");
await enterQuery("x".repeat(255) + "\u{1f63a}");
assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair");
await enterQuery("[](){}");
assert.equal(rows().length, 0);
assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/);
assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/);
assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/);
assert.match(renderer.container.textContent ?? "", /not full-text search/);
await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button"));
assert.equal(renderer.container.querySelector("input")?.value, "");
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await enterQuery(" \t");
assert.equal(rows().length, 80);
await click(renderer.container.querySelector("button"));
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = MESSAGES_BY_LOCALE[locale];
const input = renderer.container.querySelector("input")!;
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
assert.ok(messages[key], `${locale} is missing ${key}`);
}
assert.equal(input.placeholder, messages["ai.chat.searchSessions"]);
assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]);
assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
await render([], locale);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"]));
assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
}
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+5 -64
View File
@@ -1,14 +1,11 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch';
// -------------------------------------------------------------------
// Session History Drawer
@@ -34,28 +31,13 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
// One cache instance per mounted drawer: caches of concurrently mounted
// drawers stay independent (no shared state to prune or discard across tabs).
const search = useSessionHistorySearch();
const filteredSessions = React.useMemo(
// The localized fallback below is displayed for untitled sessions
// (row label), so it must be indexed for search as the displayed title.
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
[sessions, searchQuery, t, search],
);
// Reset the render batch when the list scope or query changes so matching
// sessions are never hidden behind stale paging.
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions, searchQuery]);
}, [sessions]);
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -68,41 +50,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<X size={14} />
</button>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
/>
<Input
ref={searchInputRef}
type="text"
value={searchQuery}
maxLength={MAX_SEARCH_QUERY_LENGTH}
placeholder={t('ai.chat.searchSessions')}
aria-label={t('ai.chat.searchSessions')}
aria-describedby={searchHintId}
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
className="h-8 pl-8 pr-7 text-[12px]"
/>
{searchQuery && (
<button
type="button"
aria-label={t('ai.chat.clearSessionSearch')}
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
{t('ai.chat.searchSessionsHint')}
</p>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
@@ -111,12 +58,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.noSessions')}
</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noMatchingSessions')}
</p>
</div>
) : (
<>
{hiddenSessionCount > 0 && (
+1 -3
View File
@@ -669,9 +669,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
evt,
// No terminal settings loaded yet -> do not close the tab; auto-close is
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
terminalSettings?.autoCloseOnExit ?? false,
terminalSettings?.autoCloseOnExit ?? true,
);
if (intent.kind === "closeSession") {
handleCloseSession(sessionId);
+1 -1
View File
@@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => {
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
assert.match(
terminalLayerSource,
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
);
assert.match(
terminalSource,
@@ -1,86 +0,0 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import React from "react";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
async function setup(t: TestContext, label: string) {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
DocumentFragment: env.window.DocumentFragment,
MutationObserver: env.window.MutationObserver,
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 });
}
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
const { Tabs } = await import("../../ui/tabs");
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();
});
const updates: string[][] = [];
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
hotkeyScheme="pc" setHotkeyScheme={() => {}}
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
updateKeyBinding={(...args) => { updates.push(args); }}
/></Tabs></I18nProvider>);
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
const button = row?.querySelector("button");
assert.ok(button);
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
);
return { updates, key, button };
}
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Alt", code: "AltLeft" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
});
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Control", ctrlKey: true });
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
await key("keyup", { key: "Alt", ctrlKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Control" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
});
test("ordinary shortcuts still require a non-modifier key", async (t) => {
const { updates, key } = await setup(t, "Next Tab");
await key("keydown", { key: "Alt", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
await key("keydown", { key: "ArrowRight", altKey: true });
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
});
test("Escape cancels an unfinished modifier recording", async (t) => {
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", altKey: true });
await key("keydown", { key: "Escape", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
assert.equal(button.textContent, "Ctrl");
});
@@ -64,7 +64,6 @@ export default function SettingsShortcutsTab(props: {
if (!recordingBindingId || !recordingScheme) return;
const specialSuffix = getSpecialSuffix(recordingBindingId);
let pendingModifierBinding: string | null = null;
const handleKeyDown = (e: KeyboardEvent) => {
// Skip while app lock overlay is up so password keys never bind (Codex P2).
@@ -78,6 +77,8 @@ export default function SettingsShortcutsTab(props: {
}
if (specialSuffix) {
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const parts: string[] = [];
if (recordingScheme === "mac") {
if (e.metaKey) parts.push("⌘");
@@ -94,11 +95,6 @@ export default function SettingsShortcutsTab(props: {
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
const fullKeyString = modifierString + specialSuffix;
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
pendingModifierBinding = fullKeyString;
return;
}
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
cancelRecording();
return;
@@ -110,17 +106,6 @@ export default function SettingsShortcutsTab(props: {
cancelRecording();
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
e.preventDefault();
e.stopPropagation();
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
cancelRecording();
};
const handleClick = () => {
cancelRecording();
};
@@ -130,11 +115,9 @@ export default function SettingsShortcutsTab(props: {
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("click", handleClick, true);
};
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
@@ -1,7 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { reconcileAutocompletePopupState, type AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
/**
* Regression test for #3426: hovering autocomplete rows must not change the
@@ -11,7 +10,7 @@ import { reconcileAutocompletePopupState, type AutocompleteState } from "./autoc
* the hovered row and self-oscillating (violent mousemove jitter).
*/
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async (t) => {
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
@@ -195,38 +194,6 @@ test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", as
}
}
await t.test("edited snippet rows cannot be clicked before the provider refresh", async () => {
let clicks = 0;
const state: AutocompleteState = {
suggestions: [{ text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
snippet: { id: "deploy", label: "deploy", command: "echo deploy" } }],
selectedIndex: -1, popupVisible: true,
popupAnchorViewport: { left: 100, top: 300, bottom: 316 },
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
};
const renderPopup = async (next: AutocompleteState) => {
await act(async () => {
root.render(<AutocompletePopup
suggestions={next.suggestions} selectedIndex={next.selectedIndex}
anchorViewport={next.popupAnchorViewport} visible={next.popupVisible}
onSelect={() => { clicks++; }} containerRef={containerRef}
/>);
});
};
await renderPopup(state);
const label = Array.from(rootNode.querySelectorAll<HTMLElement>("span"))
.find((el) => el.textContent === "deploy");
const row = label?.closest<HTMLElement>("div[style*='cursor']");
assert.ok(row);
await act(async () => {
row.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true }));
});
assert.equal(clicks, 1, "an unselected fresh snippet is clickable");
await renderPopup(reconcileAutocompletePopupState(state, "git pull xyz"));
assert.equal(Boolean(rootNode.querySelector(".xterm-autocomplete-popup")), false);
assert.equal(clicks, 1);
});
await act(async () => {
root.unmount();
});
@@ -406,32 +406,16 @@ export async function getCompletions(
}
}
// Snippets: at the command position the whole typed line is the needle. Once
// arguments are present, match against the word being typed so a snippet
// (e.g. an email shortcut) stays reachable after a command like `git pull `
// (#3622). Argument-position matching is prefix-only, needs at least two
// typed characters, and yields when path completion owns the slot.
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0) {
const atCommandPosition = ctx.wordIndex === 0;
const argumentNeedle = normalizeHistoryPathPrefix(ctx.currentWord);
const snippetArgumentMatch = !atCommandPosition
&& !preferPathSuggestions
&& argumentNeedle.trim().length >= 2;
if (atCommandPosition || snippetArgumentMatch) {
for (const snippetSuggestion of getSnippetSuggestions(
atCommandPosition ? input : argumentNeedle,
options.snippets,
{
hostId,
hostGroup: options.hostGroup,
prefixOnly: !atCommandPosition,
},
)) {
suggestions.push(snippetSuggestion);
}
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
@@ -8,7 +8,7 @@
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery, matchesWordPrefixQuery } from "../../../lib/searchMatcher";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
@@ -30,7 +30,7 @@ function snippetAvailableForAutocomplete(
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string; hostGroup?: string; prefixOnly?: boolean } = {},
options: { hostId?: string; hostGroup?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
@@ -44,16 +44,10 @@ export function getSnippetSuggestions(
const labelPrefix = label.startsWith(needle);
// Literal prefix/substring first (cheap); fall back to shared smart matcher
// so Chinese titles surface for pinyin / initials the same way host search does.
// In prefixOnly mode (argument position), matches must start at a word
// boundary to avoid flooding mid-argument popups; pinyin still applies.
const matches = options.prefixOnly
? labelPrefix
|| firstLine.startsWith(needle)
|| matchesWordPrefixQuery(needle, snippet.label, firstLine)
: labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
if (!matches) continue;
out.push({
@@ -144,8 +144,8 @@ function isSubsequence(query: string, target: string): boolean {
/**
* Remove history rows that no longer match the edited input while a debounced
* provider refresh is pending. Snippets must be re-queried for the new input
* before they can be selected or executed. Other sources retain their matching.
* provider refresh is pending. Other sources keep their provider-specific
* matching semantics (snippet search, plugin replacement, etc.).
*/
export function reconcileAutocompletePopupState(
prev: AutocompleteState,
@@ -158,7 +158,6 @@ export function reconcileAutocompletePopupState(
const inputContext = parseCommandLine(input);
const allowFuzzyHistory = input.length >= 2 && inputContext.wordIndex === 0;
const suggestions = prev.suggestions.filter((suggestion) => {
if (suggestion.source === "snippet") return false;
if (suggestion.source !== "history") return true;
const text = suggestion.text.toLowerCase();
if (suggestion.historyMatch === "path-argument") {
@@ -14,28 +14,8 @@ test("getCompletions includes snippet suggestions at the command position", asyn
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions surfaces prefix-matching snippets while typing an argument (#3622)", async () => {
const out = await getCompletions("git pull dep", { snippets: [deploySnippet] });
const snip = out.find((s) => s.source === "snippet");
assert.ok(snip, "expected a snippet suggestion while typing an argument");
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions does not surface snippets for mid-word argument matches", async () => {
// Argument-position matching works on word boundaries only: "mai" sits
// mid-word in "gmail" and must not surface the snippet.
const substringSnippet: Snippet = { id: "gm", label: "gmail", command: "echo gmail" };
const out = await getCompletions("git push mai", { snippets: [substringSnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
test("getCompletions does not surface snippets for single-character arguments", async () => {
const out = await getCompletions("git pull d", { snippets: [deploySnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
test("getCompletions yields argument-position snippets to path completion", async () => {
const out = await getCompletions("cat dep", { snippets: [deploySnippet] });
test("getCompletions does not surface snippets past the command position", async () => {
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
@@ -1,258 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
attachWindowResizeFocusRestore,
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
type MinimalDocument,
} from "./restoreWindowResizeFocus";
type FakeWindow = {
listeners: Map<string, Array<() => void>>;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
emit: (type: string) => void;
};
const createWindowStub = (): FakeWindow => {
const listeners = new Map<string, Array<() => void>>();
return {
listeners,
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
bucket.push(listener);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
const index = bucket.indexOf(listener);
if (index >= 0) bucket.splice(index, 1);
},
emit(type) {
for (const listener of [...(listeners.get(type) ?? [])]) listener();
},
};
};
type SchedulerState = {
schedule: (callback: () => void, delay: number) => number;
cancel: (id: number) => void;
runNext: () => void;
pendingCount: () => number;
};
const createScheduler = (): SchedulerState => {
type Entry = { id: number; callback: () => void; cancelled: boolean };
const queue: Entry[] = [];
let nextId = 1;
return {
schedule(callback, delay) {
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
queue.push({ id: nextId, callback, cancelled: false });
return nextId++;
},
cancel(id) {
for (const entry of queue) {
if (entry.id === id) entry.cancelled = true;
}
},
runNext() {
while (queue.length) {
const entry = queue.shift()!;
if (!entry.cancelled) {
entry.callback();
return;
}
}
},
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
};
};
test("resize restores terminal focus once the resize settles", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
assert.equal(refocusCalls, 0);
scheduler.runNext();
assert.equal(refocusCalls, 1);
detach();
});
test("a continuous frame drag only triggers one restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
win.emit("resize");
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
detach();
});
test("resize does not steal focus when the app window is not focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize restore is skipped while a modal dialog is open", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: (selector: string) => (
selector === '[role="dialog"][data-state="open"]' ? {} : null
),
hasFocus: () => true,
} as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize does not steal focus from an active editable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "INPUT", matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
detach();
});
test("resize does not steal focus from a contenteditable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize still restores while the xterm helper textarea is focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: {
tagName: "TEXTAREA",
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
},
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
detach();
});
test("detach removes the listener and cancels a pending restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
detach();
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
scheduler.runNext();
assert.equal(refocusCalls, 0);
});
test("a win without listener support degrades to a no-op detach", () => {
const detach = attachWindowResizeFocusRestore({
win: null,
refocus: () => {},
scheduleTimeout: () => 1,
cancelTimeout: () => {},
});
assert.doesNotThrow(() => detach());
});
@@ -1,104 +0,0 @@
import { hasOpenAppDialog } from "./focusTerminalSession";
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
export type MinimalWindow = {
addEventListener?: (type: string, listener: () => void) => unknown;
removeEventListener?: (type: string, listener: () => void) => unknown;
};
export type MinimalActiveElement = {
tagName?: string | null;
isContentEditable?: boolean;
matches?: (selector: string) => boolean;
};
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
hasFocus?: () => boolean;
activeElement?: MinimalActiveElement | null;
};
/**
* True when keyboard focus sits in an editable app field (search box, chat
* input, SFTP filter, inline rename, ...) that is not an xterm helper
* textarea. Resize-driven restores must not yank focus away from such fields.
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
* as editing another field.
*/
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
const element = doc?.activeElement;
if (!element) return false;
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
return false;
}
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
return (
tagName === "input"
|| tagName === "textarea"
|| tagName === "select"
|| Boolean(element.isContentEditable)
);
};
type AttachWindowResizeFocusRestoreOptions = {
win?: MinimalWindow | null;
doc?: MinimalDocument | null;
refocus?: () => void;
delayMs?: number;
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
cancelTimeout?: (id: unknown) => void;
};
/**
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
* helper textarea unfocused, so keystrokes do nothing until the user clicks
* back into the terminal. Attach a window `resize` listener that re-runs the
* tab-change refocus path after the resize settles.
*
* Restore is skipped when the app window does not hold OS input focus, when a
* modal dialog is open, or when an editable element other than the xterm
* helper textarea already holds focus, so the restore never steals typing
* from elsewhere.
*/
export const attachWindowResizeFocusRestore = (
{
win = typeof window !== "undefined" ? window : null,
doc = typeof document !== "undefined" ? document : null,
refocus,
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
}: AttachWindowResizeFocusRestoreOptions = {},
): (() => void) => {
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
return () => {};
}
let timerId: unknown = null;
const restoreFocus = () => {
timerId = null;
if (doc?.hasFocus?.() === false) return;
if (hasOpenAppDialog(doc)) return;
// The window holds OS focus, but the user may still be typing in a
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
// Never steal focus from an active editable element.
if (hasActiveEditableElement(doc)) return;
refocus();
};
const handleResize = () => {
if (timerId !== null) {
cancelTimeout(timerId);
}
timerId = scheduleTimeout(restoreFocus, delayMs);
};
win.addEventListener("resize", handleResize);
return () => {
win.removeEventListener("resize", handleResize);
if (timerId !== null) {
cancelTimeout(timerId);
timerId = null;
}
};
};
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
onDispose: (handler: Handler) => { dispose: () => void };
}> = [];
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
const markers: Array<{ line: number; disposed: boolean }> = [];
const term = {
cols,
@@ -477,84 +477,6 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
highlighter.dispose();
});
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
// xterm-side disposal (decoration service reset / term.reset): no cursor
// move and no write follow, only the next render frame.
term.resetDecorations();
assert.equal(term.decorations[0]?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.decorations.at(-1)?.options.width, 80);
highlighter.dispose();
});
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.disposed, false);
// term.clear() disposes every marker; decorations follow their markers.
for (const marker of term.markers) marker.dispose();
term.resetDecorations();
assert.equal(term.decorations.at(-1)?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.markers.at(-1)?.line, 0);
highlighter.dispose();
});
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
term.render();
term.render();
assert.equal(
term.decorations.filter((decoration) => !decoration.disposed).length,
aliveBefore,
);
highlighter.dispose();
});
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
const cols = 8;
const term = createFakeTerm(cols);
// Every cell has an explicit ANSI background and the row is full, so both
// highlight ranges and the blank tail are legitimately empty.
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 0);
assert.equal(term.markers.length, 1);
const marker = term.markers[0];
assert.ok(marker);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
term.render();
term.render();
term.render();
assert.equal(term.markers.length, 1);
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
assert.equal(term.decorations.length, 0);
highlighter.dispose();
});
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
@@ -37,8 +37,6 @@ export class CursorLineHighlighter implements IDisposable {
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
/** False once any tracked decoration has been disposed out from under us. */
private decorationsAlive = false;
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
@@ -48,16 +46,10 @@ export class CursorLineHighlighter implements IDisposable {
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
const pending = this.pendingRefresh;
this.pendingRefresh = false;
if (this.disposed || !this.enabled) return;
// Writes and cursor moves defer to render, but xterm can also dispose
// or clear our decorations out from under us (term.clear/reset,
// decoration service resets, marker reindexing) without any further
// cursor move or write. Verify the tracked set on every render so the
// highlight can self-heal at the cursor line instead of staying
// anchored to stale bookkeeping (wrong/missing line).
if (pending || !this.isTrackingIntact()) this.refresh();
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
@@ -119,7 +111,6 @@ export class CursorLineHighlighter implements IDisposable {
color === this.activeColor &&
rangesEqual(ranges, this.activeRanges) &&
rangesEqual(tailRanges, this.activeTailRanges) &&
this.decorationsAlive &&
this.marker &&
!this.marker.isDisposed &&
this.marker.line === absoluteLine
@@ -169,18 +160,13 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = marker;
this.decorations = decorations;
// An empty set is valid when the whole row already has ANSI backgrounds or
// inverse cells. Tracking stays intact so later frames do not churn markers.
this.decorationsAlive = true;
this.decorationDisposeListeners = decorations.map((decoration) =>
decoration.onDispose(() => {
if (this.decorations.includes(decoration)) {
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
this.activeTailRanges = [];
}
}),
);
@@ -258,7 +244,6 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
@@ -266,19 +251,6 @@ export class CursorLineHighlighter implements IDisposable {
this.activeTailRanges = [];
}
/**
* Cheap render-time check that the tracked marker and decorations are still
* owned by xterm. xterm disposes highlight decorations on buffer clears,
* resets, and marker reindexing without emitting cursor-move/write events,
* so a stale tracking state must be recreated before the next frame paints.
*/
private isTrackingIntact(): boolean {
if (this.marker === null || this.marker.isDisposed) {
return this.marker === null && this.decorations.length === 0;
}
return this.decorationsAlive;
}
private clearOwned(
marker: IMarker | null,
decorations: IDecoration[],
@@ -111,31 +111,3 @@ test("matches Chinese labels by pinyin and initials (smart suggest)", () => {
assert.equal(getSnippetSuggestions("bushu", [s], {})[0]?.snippet?.id, "zh");
assert.equal(getSnippetSuggestions("bsfw", [s], {})[0]?.snippet?.id, "zh");
});
test("prefixOnly mode matches at word boundaries (argument position, #3622)", () => {
const s = snip({ id: "mail", label: "set-email", command: "git config user.email a@b.c" });
// First 3 letters of the label's last word still match while typing an
// argument, e.g. after `git pull `.
assert.equal(getSnippetSuggestions("ema", [s], { prefixOnly: true })[0]?.snippet?.id, "mail");
// Mid-word substrings do not match.
const gmail = snip({ id: "gm", label: "gmail", command: "echo gmail" });
assert.deepEqual(getSnippetSuggestions("mai", [gmail], { prefixOnly: true }), []);
});
test("prefixOnly mode matches Chinese labels via pinyin and initials", () => {
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply" });
assert.equal(getSnippetSuggestions("bush", [s], { prefixOnly: true })[0]?.snippet?.id, "zh");
const mailbox = snip({ id: "yx", label: "邮箱", command: "user@example.com" });
assert.equal(getSnippetSuggestions("yx", [mailbox], { prefixOnly: true })[0]?.snippet?.id, "yx");
});
test("prefixOnly mode does not match option-prefixed needles (#3623)", () => {
// The pinyin fallback must not strip the leading "--"/"-" punctuation:
// "--ve" (from `git --ve`) would otherwise match "version", and "-d"
// (from `git -d`) match "deploy" via a single typed letter, displacing
// the real Fig option completions while typing flags.
const version = snip({ id: "v", label: "version", command: "echo version" });
const deploy = snip({ id: "d", label: "deploy", command: "kubectl apply" });
assert.deepEqual(getSnippetSuggestions("--ve", [version], { prefixOnly: true }), []);
assert.deepEqual(getSnippetSuggestions("-d", [deploy], { prefixOnly: true }), []);
});
@@ -177,7 +177,7 @@ test("path history follows the current argument instead of the whole line", () =
assert.equal(changedArgument.popupVisible, false);
});
test("snippet rows are removed synchronously while their next query is pending", () => {
test("provider-specific non-history rows remain until their debounced refresh", () => {
const snippet: CompletionSuggestion = {
text: "deploy production",
displayText: "deploy production",
@@ -186,8 +186,7 @@ test("snippet rows are removed synchronously while their next query is pending",
};
const next = reconcileAutocompletePopupState(popupState([snippet]), "dp");
assert.deepEqual(next.suggestions, []);
assert.equal(next.popupVisible, false);
assert.deepEqual(next.suggestions, [snippet]);
assert.equal(next.selectedIndex, -1);
});
@@ -1,8 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
const suggestion = (text: string) => ({
@@ -350,32 +348,3 @@ test("subdir Escape does not arm Meta prefix", () => {
assert.equal(context.escMetaPrefixUntilRef.current, 0);
});
for (const edited of [false, true]) {
test(`snippet navigation before provider refresh: edited=${edited}`, () => {
let runs = 0;
const initial: AutocompleteState = {
suggestions: [{
text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
snippet: { id: "deploy", label: "deploy", command: "echo deploy" },
}],
selectedIndex: -1, popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
};
const stateRef = { current: edited ? reconcileAutocompletePopupState(initial, "git pull xyz") : initial };
const { context } = createContext({
stateRef,
setState(update: AutocompleteState | ((prev: AutocompleteState) => AutocompleteState)) {
stateRef.current = typeof update === "function" ? update(stateRef.current) : update;
},
acceptSnippet() { runs++; return true; },
});
// An unselected snippet does not execute on direct Enter.
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
assert.equal(runs, 0);
handleTerminalAutocompleteKeyEvent(keyEvent("ArrowDown"), context);
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
assert.equal(runs, edited ? 0 : 1);
});
}
@@ -23,7 +23,6 @@ import {
type SidePanelTabRemap,
} from '../../domain/workspaceSidePanelTabRemap';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
import type { RendererCwdSource } from '../terminal/sftpCwd';
@@ -748,23 +747,6 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
refocusActiveTerminalSession();
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
// Restore keyboard focus after the OS window is resized or maximized.
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
// textarea unfocused on Windows, so keystrokes do nothing until a click.
// Reuse the tab-change refocus path; the helper only fires once the resize
// settles and is gated on the app window actually holding input focus.
useEffect(() => {
if (!isTerminalLayerVisible) return undefined;
return attachWindowResizeFocusRestore({
win: window,
doc: document,
refocus: () => {
if (isComposeBarOpen) return;
refocusActiveTerminalSession?.();
},
});
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state
useEffect(() => {
+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</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="226.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="301.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="373.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="447.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="520.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="594.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="668.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="741.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="161.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="306.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="380.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="457.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="531.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="607.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="683.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="757.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 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.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<circle cx="765.0" cy="106.9" 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">6508</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<circle cx="765.0" cy="113.6" 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">6385</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: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</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="226.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="301.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="373.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="447.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="520.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="594.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="668.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="741.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="161.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="306.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="380.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="457.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="531.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="607.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="683.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="757.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 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.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<circle cx="765.0" cy="106.9" 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">6508</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<circle cx="765.0" cy="113.6" 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">6385</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: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+2 -9
View File
@@ -536,10 +536,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
middleClickPaste: true,
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
linkModifier: 'none',
// Off by default: remote graceful channel closes can be reported as a clean
// exit (code 0), which would auto-close tabs on network drops. Users who
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
autoCloseOnExit: false,
autoCloseOnExit: true,
// Issue #3087: keep terminal history visible after an established session disconnects.
disconnectedNoticeMode: 'terminal',
keywordHighlightEnabled: true,
@@ -555,11 +552,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
// in-place reconnect every 5s with the same session id. Clean shell exits
// still close the tab, and administrative idle/timeout exits stay
// disconnected. Users on always-stable links can turn it off.
sshAutoReconnectEnabled: true,
sshAutoReconnectEnabled: false,
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showHostInfoBar: true, // Preserve the existing host information bar by default
-47
View File
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
PINYIN_CACHE_MAX_CHARS,
PINYIN_CACHE_MAX_ENTRIES,
getPinyinCacheStatsForTests,
getHostSearchMatch,
@@ -12,21 +11,6 @@ import {
resetPinyinCacheForTests,
} from "../lib/searchMatcher.ts";
test("shared pinyin search reaches later fields after a long first field", () => {
const prefix = "a".repeat(256);
for (const query of ["fuwuqi", "fwq", "nginx fuwuqi"]) {
assert.equal(matchesSearchQuery(query, `${prefix} nginx`, "服务器"), true);
}
assert.equal(matchesSearchQuery("fuwuqi", prefix, "服务器"), true);
assert.equal(matchesSearchQuery("fuwuqi missing", prefix, "服务器"), false);
});
test("shared pinyin search preserves full fields and host labels", () => {
const label = `${"a".repeat(256)}服务器`;
assert.equal(matchesSearchQuery("fuwuqi", label), true);
assert.equal(matchesHostSearchQuery("fuwuqi", { label }), true);
});
test("matches mixed Chinese and dash-separated numeric suffix with spaced query", () => {
assert.equal(
matchesSearchQuery("山东 6-1", "山东-业务交换机6-1"),
@@ -141,18 +125,6 @@ test("punctuation-only query does not match every field", () => {
assert.equal(matchesSearchQuery("-", "some-host"), false);
});
test("over-limit query keeps its full meaning instead of matching on a truncated prefix", () => {
// The shared matcher never truncates: a >256-char query whose distinguishing
// suffix is beyond the suggested `MAX_SEARCH_QUERY_LENGTH` cap must not
// match a field that only contains the cap-bounded prefix. Callers that
// rescan many haystacks (session history) bound their own input instead.
const field = "a".repeat(300);
const query = `${"a".repeat(300)} missing-suffix`;
assert.equal(matchesSearchQuery(query, field), false);
// The same query still matches when the whole query is present.
assert.equal(matchesSearchQuery(query, `${field} missing-suffix`), true);
});
test("host search avoids compact hostname false positives on numeric segments", () => {
assert.equal(
matchesHostSearchQuery("61", {
@@ -255,22 +227,3 @@ test("pinyin cache covers 8000 hosts and stays hard-bounded through import/edit
assert.ok(!stats.keys.includes("主机1"));
resetPinyinCacheForTests();
});
// Regression: short labels keep the pinyin cache under the entry cap, but the
// entry cap alone is not a memory bound — a Latin query that matches nothing
// transliterates every distinct field it scans, so long fields (e.g. up to
// MAX_SEARCHABLE_FIELD_LENGTH-character session titles) could retain several
// copies of each source in the global cache. The cache must also be bounded
// by retained characters.
test("pinyin cache stays hard-bounded by retained characters, not just entries", () => {
resetPinyinCacheForTests();
// 17 long CJK titles (~20_000 chars each): their entries can never all fit
// under the 1,000,000-char budget, so earlier ones must be evicted instead
// of pinning memory within the 16_384-entry cap.
for (let index = 0; index < 17; index += 1) {
matchesSearchQuery("unmatched-latin", "重启".repeat(10_000) + String(index));
}
assert.ok(getPinyinCacheStatsForTests().chars <= PINYIN_CACHE_MAX_CHARS);
assert.ok(getPinyinCacheStatsForTests().size > 0);
resetPinyinCacheForTests();
});
-414
View File
@@ -1,414 +0,0 @@
import type { AgentActivity } from './agentActivity';
import {
matchesPreparedSearchQuery,
prepareSearchFields,
type PreparedSearchFields,
type PreparedSearchQuery,
} from '../lib/searchMatcher';
/**
* Minimal structural shapes for session history search. Declared here (not
* imported from `infrastructure/ai/types`) so this domain module keeps no
* dependency on the infrastructure layer; persisted `AISession`s satisfy
* these shapes structurally.
*/
export type SessionHistorySearchAttachment = {
/** Rendered file label (absent for terminal selections and note-only files). */
filename?: string;
/** Rendered Vault note label. */
vaultNoteTitle?: string;
};
export type SessionHistorySearchMessage = {
/** Persisted role; 'system' messages are hidden by the UI and never indexed. */
role?: string;
content: string;
thinking?: string;
statusText?: string;
errorInfo?: { message: string };
attachments?: readonly SessionHistorySearchAttachment[];
/** @deprecated Legacy attachment field, kept for backward compatibility. */
images?: readonly SessionHistorySearchAttachment[];
toolCalls?: readonly { name: string; arguments: Record<string, unknown> }[];
toolResults?: readonly { toolName?: string; content: string }[];
agentActivities?: readonly AgentActivity[];
};
/**
* Searchable shape for session history search. Pure domain logic consumed by
* the session history drawer; matches on title plus user/assistant message
* text (hidden `system` messages are excluded, mirroring the reopened-chat
* rendering), including thinking, tool call names/arguments, tool result content,
* persisted agent activities (web-search queries, file paths, plan items,
* warnings) already stored on messages, visible attachment labels (file names
* and Vault note titles) and persisted error messages.
*/
export type SessionHistorySearchTarget = {
/** Raw persisted title; may be empty (the UI then renders a fallback). */
title: string;
messages: readonly SessionHistorySearchMessage[];
};
/** Cap per-field text so very large tool result payloads stay cheap to scan. */
const MAX_SEARCHABLE_FIELD_LENGTH = 20_000;
/** Bound history-only title transliteration without limiting shared search. */
const MAX_TITLE_PINYIN_LENGTH = 256;
/**
* Cap the total searchable text collected per session. Pinyin matching joins
* every collected field into one haystack, so a long retained session with
* hundreds of large messages could otherwise contribute megabytes that gets
* re-normalized (and pinyin-transliterated) on every keystroke.
*/
const MAX_SESSION_SEARCHABLE_TOTAL_LENGTH = 64_000;
/** Cap serialized tool-call argument text so bulky payloads stay cheap to scan. */
const MAX_TOOL_ARGUMENTS_FIELD_LENGTH = 2_000;
type SearchFieldCollector = {
fields: string[];
push: (text: unknown, maxLength?: number) => void;
/** True once the per-session budget is exhausted: further fields are dropped. */
isFull: boolean;
};
function createSearchFieldCollector(): SearchFieldCollector {
const fields: string[] = [];
let totalLength = 0;
const collector = {
fields,
push(text: unknown, maxLength: number = MAX_SEARCHABLE_FIELD_LENGTH) {
if (typeof text !== 'string') return;
// Fill the remaining per-session budget with the freshest evidence:
// truncate an oversized field instead of discarding it wholesale (whole-
// field rejection would let older, smaller fields win the leftover space).
const remaining = MAX_SESSION_SEARCHABLE_TOTAL_LENGTH - totalLength;
if (remaining <= 0) return;
const effectiveMaxLength = Math.min(maxLength, MAX_SEARCHABLE_FIELD_LENGTH, remaining);
// Bound the input before trimming so whitespace outside the searchable
// window cannot trigger an unbounded scan on every keystroke.
const capped = text.slice(0, effectiveMaxLength).trim();
if (!capped) return;
totalLength += capped.length;
fields.push(capped);
},
get isFull() {
return totalLength >= MAX_SESSION_SEARCHABLE_TOTAL_LENGTH;
},
};
return collector;
}
/** Depth bound for argument traversal so pathological nesting cannot recurse unboundedly. */
const MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH = 12;
/**
* Serialize tool-call arguments within a hard character budget: traversal and
* emitted text both stop as soon as the field cap is reached, so a retained
* call carrying a multi-megabyte blob (e.g. an uncapped `content` for
* `sftp_write_file`) never materializes in full on every search keystroke.
* The output mirrors `JSON.stringify` for the region it covers.
*/
function serializeToolCallArguments(args: Record<string, unknown>): string {
try {
let out = '';
let exhausted = false;
const emit = (text: string): boolean => {
const remaining = MAX_TOOL_ARGUMENTS_FIELD_LENGTH - out.length;
if (remaining <= 0) {
exhausted = true;
return false;
}
if (text.length > remaining) {
out += text.slice(0, remaining);
exhausted = true;
return false;
}
out += text;
return true;
};
const writeValue = (value: unknown, depth: number): boolean => {
if (exhausted) return false;
if (value === null || value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return emit('null');
}
switch (typeof value) {
case 'boolean':
return emit(value ? 'true' : 'false');
case 'number':
return emit(Number.isFinite(value) ? String(value) : 'null');
case 'bigint':
// `JSON.stringify` throws on BigInt; degrade to a cappable string.
return emit(JSON.stringify(String(value)) ?? 'null');
case 'string':
// Keep per-value allocation bounded too: escaping a multi-megabyte
// string whole would defeat the budget even if `emit` slices after.
return emit(
JSON.stringify(
value.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
? value.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
: value,
) ?? 'null',
);
default: {
if (depth <= 0) return emit('null');
if (Array.isArray(value)) {
if (!emit('[')) return false;
let first = true;
for (const item of value) {
if (!first && !emit(',')) return false;
first = false;
if (!writeValue(item, depth - 1)) return false;
}
return emit(']');
}
if (typeof value === 'object') {
if (!emit('{')) return false;
let first = true;
// Iterate own properties lazily instead of materializing the full
// `Object.entries` array first: an argument object with thousands
// of keys (or one very large key) must not allocate for every
// keystroke just to have most of it sliced away again.
for (const key in value as Record<string, unknown>) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
if (!first && !emit(',')) return false;
first = false;
// Cap the key before escaping it: escaping a multi-megabyte
// property name whole would defeat the per-field budget.
const serializedKey = JSON.stringify(
key.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
? key.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
: key,
) ?? 'null';
if (!emit(serializedKey)) return false;
if (!emit(':')) return false;
if (!writeValue(value[key as keyof typeof value], depth - 1)) return false;
}
return emit('}');
}
return emit('null');
}
}
};
writeValue(args, MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH);
return out;
} catch {
return '';
}
}
/**
* Collect the human-visible text carried by persisted agent activities
* (web-search queries, changed file paths, plan items, warnings) as
* individual fields. For external SDK sessions these live only in
* `message.agentActivities` and are rendered when the conversation is
* reopened, so they must be indexed too. Each value is fed to the collector
* on its own (capped per value, not per message) so activities accumulated
* later in one message stay searchable, and values are pushed newest-first to
* match the newest-first budget allocation of the message loop.
*/
function collectAgentActivityFields(
collector: SearchFieldCollector,
activities: readonly AgentActivity[],
): void {
for (let i = activities.length - 1; i >= 0; i--) {
if (collector.isFull) return;
const activity = activities[i];
switch (activity.type) {
case 'file_change':
for (let j = activity.changes.length - 1; j >= 0; j--) {
if (collector.isFull) return;
collector.push(activity.changes[j].path, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
break;
case 'web_search':
collector.push(activity.query, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
break;
case 'plan_update':
for (let j = activity.items.length - 1; j >= 0; j--) {
if (collector.isFull) return;
collector.push(activity.items[j].text, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
break;
case 'warning':
collector.push(activity.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
break;
}
}
}
/**
* Collect the human-visible labels of a message's attachments: file names
* and Vault note titles. Rendered from `message.attachments` when the
* conversation is reopened (with the legacy `images` field as fallback), so
* a session whose only mention of a term sits in an attachment label must
* stay findable. Only the short labels are indexed — never the base64
* payloads — so attaching files cannot blow up the search haystack.
*/
function collectAttachmentLabelFields(
collector: SearchFieldCollector,
attachments: readonly SessionHistorySearchAttachment[],
): void {
// Individual labels are handed to the collector newest-first so the 2,000-
// character field cap (and the remaining session budget) can truncate the
// list at its oldest end instead of swallowing every later label from the
// reopened conversation.
for (let i = attachments.length - 1; i >= 0; i--) {
if (collector.isFull) return;
const attachment = attachments[i];
collector.push(attachment.vaultNoteTitle, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
if (collector.isFull) return;
collector.push(attachment.filename, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
}
/**
* The title exactly as the history drawer renders it: an empty persisted
* title is displayed with the localized "Untitled" fallback, so the fallback
* text (passed by the caller) must be indexed too — searching for the label
* the user actually sees must not hide the session.
*/
function resolveDisplayTitle(session: SessionHistorySearchTarget, untitledLabel: string): string {
return session.title || untitledLabel;
}
export function collectSessionSearchFields(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): string[] {
const collector = createSearchFieldCollector();
// The displayed title is indexed first so it always survives the
// total-length cap. Still skipped entirely when both the raw title and the
// fallback are blank.
collector.push(resolveDisplayTitle(session, untitledLabel));
// Fields pushed before the message loop (0 or 1 entries; the title may be
// blank and therefore skipped).
const headCount = collector.fields.length;
// Allocate the remaining budget newest-first: when a long session exceeds
// the cap, recent messages stay searchable instead of being silently
// dropped in favor of the oldest content.
for (let i = session.messages.length - 1; i >= 0; i--) {
// Stop as soon as the per-session budget is exhausted: older messages
// cannot contribute anything, and traversing them (or serializing their
// tool arguments/activities) would waste work on every keystroke.
if (collector.isFull) break;
const message = session.messages[i];
// `ChatMessageList` removes `system` messages from the reopened
// conversation, so their (hidden) content must not make a session
// searchable — otherwise a match would show no matching text when opened.
if (message.role === 'system') continue;
collector.push(message.content);
// `push` drops fields once the budget is spent; skip the remaining
// (potentially expensive) serialization for this message too.
if (collector.isFull) continue;
collector.push(message.thinking);
if (collector.isFull) continue;
// Persisted status text: when an external SDK turn stops after an
// `onStatus` update, the final status remains in `message.statusText`,
// is persisted, and is the only distinctive visible text rendered in the
// reopened conversation. Status strings stay short, hence the tight cap.
collector.push(message.statusText, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
if (collector.isFull) continue;
// Persisted failure diagnostics: when a Catty turn fails with empty
// content the error message is the only visible text in the reopened
// conversation, so it must be indexed. Error strings are short, hence the
// tight cap.
collector.push(message.errorInfo?.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
collectAttachmentLabelFields(collector, message.attachments ?? message.images ?? []);
// Tool calls accumulate at the end of `message.toolCalls`, so iterate
// newest-first: if a burst of calls from this message exhausts the
// remaining session budget, the newest (still visible) calls stay
// searchable instead of being dropped in favor of the oldest ones.
const toolCalls = message.toolCalls ?? [];
for (let i = toolCalls.length - 1; i >= 0; i--) {
if (collector.isFull) break;
const toolCall = toolCalls[i];
collector.push(toolCall.name);
collector.push(serializeToolCallArguments(toolCall.arguments), MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
// Tool results accumulate at the end of `message.toolResults` (and
// `ChatMessageList` renders them in array order), so iterate newest-first:
// if a burst of large results from this message exhausts the remaining
// session budget, the newest (still visible) results stay searchable
// instead of being dropped in favor of the oldest ones.
const toolResults = message.toolResults ?? [];
for (let i = toolResults.length - 1; i >= 0; i--) {
if (collector.isFull) break;
const toolResult = toolResults[i];
collector.push(toolResult.toolName);
collector.push(toolResult.content);
}
if (collector.isFull) continue;
collectAgentActivityFields(collector, message.agentActivities ?? []);
}
const fields = collector.fields;
// Restore chronological output order: the head (title) stays first, while
// the message fields — collected newest-to-oldest — are reversed back.
const head = fields.slice(0, headCount);
const tail = fields.slice(headCount);
tail.reverse();
return [...head, ...tail];
}
export type SessionSearchIndex = {
/**
* Displayed title the index was built for (raw title or the fallback), capped
* exactly like the title field admitted by `collectSessionSearchFields`:
* this string feeds the pinyin fallback (`pinyinFields`) on every keystroke,
* so an uncapped restored title could push megabytes through pinyin
* normalization, and characters beyond the collector's cap are never indexed
* (and must not become pinyin-matchable either).
*/
displayTitle: string;
prepared: PreparedSearchFields;
};
export function getSessionSearchTitle(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): string {
const displayTitle = resolveDisplayTitle(session, untitledLabel);
// Cap the title exactly like the collector does for the title field (the
// first field pushed, so the per-field cap applies in full; trailing
// whitespace is trimmed the same way). The capped form is what the index
// stores and what the cache-change check compares, so a title that changes
// only beyond the cap reuses the cached index — correct, because the
// collector never admits those characters into the prepared haystacks
// either. A title whose capped form is blank was skipped by the collector
// entirely; the blank pinyin field is filtered out downstream.
return displayTitle.slice(0, MAX_SEARCHABLE_FIELD_LENGTH).trim();
}
export function prepareSessionSearchIndex(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): SessionSearchIndex {
const fields = collectSessionSearchFields(session, untitledLabel);
return {
displayTitle: getSessionSearchTitle(session, untitledLabel),
prepared: prepareSearchFields(fields),
};
}
export function matchesSessionSearchIndex(
query: string | PreparedSearchQuery,
index: SessionSearchIndex,
): boolean {
const titlePrefix = index.displayTitle.slice(0, MAX_TITLE_PINYIN_LENGTH);
// Do not pass a split UTF-16 surrogate pair to the transliterator.
const tail = titlePrefix.charCodeAt(titlePrefix.length - 1);
const pinyinTitle = tail >= 0xd800 && tail <= 0xdbff
? titlePrefix.slice(0, -1)
: titlePrefix;
// Pinyin transliteration is too expensive to run over every collected
// message field on each keystroke; the pinyin fallback is restricted to
// bounded title prefix, while literal/compact matching stays
// global. Restricting pinyin per field still allows tokens within one
// query to span fields: "chongqi nginx" matches a session titled
// "重启服务器" whose message contains "nginx" ("chongqi" via title
// pinyin, "nginx" literally in the message content).
return matchesPreparedSearchQuery(query, index.prepared, {
pinyinFields: [pinyinTitle],
});
}
-73
View File
@@ -280,76 +280,3 @@ test("mergeSnippetImportPayload keeps existing empty snippet packages", () => {
assert.deepEqual(result.snippetPackages, ["local-empty", "imported", "imported/ops"]);
});
test("snippet export and import preserve the all-hosts scope", () => {
const exported = buildSnippetExportPayload({
snippets: [
snippet({
id: "all-hosts",
label: "Patch everything",
command: "apt-get update",
targetsAllHosts: true,
}),
],
snippetPackages: [],
exportedAt: "2026-10-11T00:00:00.000Z",
});
// New exports carry the all-hosts scope, so they must advertise a version
// that pre-change releases do not silently accept while dropping the field.
assert.equal(exported.version, 3);
const parsed = parseSnippetImportPayload(JSON.stringify(exported));
const result = mergeSnippetImportPayload({
existingSnippets: [],
existingSnippetPackages: [],
payload: parsed,
conflictAction: "skip",
createId: () => "imported-all-hosts",
});
assert.equal(exported.snippets[0].targetsAllHosts, true);
assert.equal("targets" in exported.snippets[0], false);
assert.equal(parsed.snippets[0].targetsAllHosts, true);
assert.equal(result.snippets[0].targetsAllHosts, true);
assert.deepEqual(result.snippets[0].targets, []);
assert.equal(result.snippets[0].targetGroups, undefined);
});
test("snippet import resolves all-hosts scope against dynamic groups", () => {
const parsed = parseSnippetImportPayload(JSON.stringify({
kind: "netcatty.snippets",
version: 2,
exportedAt: "2026-10-11T00:00:00.000Z",
snippetPackages: [],
snippets: [
{
label: "All hosts beats groups",
command: "uptime",
targetGroups: ["Production"],
targetsAllHosts: true,
},
{
label: "Explicit false stays unscoped",
command: "whoami",
targetsAllHosts: false,
},
],
}));
assert.equal(parsed.snippets[0].targetsAllHosts, true);
assert.equal(parsed.snippets[0].targetGroups, undefined);
assert.deepEqual(parsed.snippets[0].targetGroups ?? [], []);
assert.deepEqual([...(parsed.snippets[0].tags ?? [])], []);
assert.equal(parsed.snippets[1].targetsAllHosts, undefined);
const merged = mergeSnippetImportPayload({
existingSnippets: [],
existingSnippetPackages: [],
payload: parsed,
conflictAction: "skip",
createId: () => "import-1",
});
assert.equal(merged.snippets[0].targetsAllHosts, true);
assert.deepEqual(merged.snippets[0].targetGroups, undefined);
assert.equal(merged.snippets[1].targetsAllHosts, undefined);
assert.deepEqual(merged.snippets[1].targetGroups, undefined);
});
+11 -46
View File
@@ -4,11 +4,7 @@ import { normalizeVaultOrder } from "./vaultOrder";
import { normalizeGroupTargetPaths } from "./hostGroupPathMutations";
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
// Version 3 introduced the portable all-hosts scope (targetsAllHosts); older
// releases silently drop that field when they accept a version-2 payload, so
// new exports advertise 3 while imports still accept versions 1 and 2.
export const SNIPPET_EXPORT_VERSION = 3 as const;
export const SNIPPET_EXPORT_VERSION_PREVIOUS = 2 as const;
export const SNIPPET_EXPORT_VERSION = 2 as const;
export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const;
export type SnippetImportConflictAction = "skip" | "overwrite";
@@ -19,7 +15,6 @@ export type SnippetExportItem = {
tags?: string[];
package?: string;
targetGroups?: string[];
targetsAllHosts?: boolean;
shortkey?: string;
noAutoRun?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
@@ -32,10 +27,7 @@ export type SnippetExportItem = {
export type SnippetExportPayload = {
kind: typeof SNIPPET_EXPORT_KIND;
version:
| typeof SNIPPET_EXPORT_VERSION
| typeof SNIPPET_EXPORT_VERSION_PREVIOUS
| typeof SNIPPET_EXPORT_VERSION_LEGACY;
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
exportedAt: string;
snippetPackages: string[];
snippets: SnippetExportItem[];
@@ -96,20 +88,12 @@ const mergeSnippetPackagePaths = (...groups: string[][]): string[] => {
return uniqueStrings(paths);
};
/**
* Concrete host-id bindings (targets) are intentionally omitted for portability;
* the portable all-hosts scope rides along and suppresses group scope on import.
*/
const toExportItem = (snippet: Snippet): SnippetExportItem => ({
label: snippet.label,
command: snippet.command,
tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [],
package: snippet.package || "",
...(snippet.targetsAllHosts
? { targetsAllHosts: true }
: snippet.targetGroups !== undefined
? { targetGroups: [...snippet.targetGroups] }
: {}),
...(snippet.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}),
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}),
@@ -150,8 +134,6 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
const label = typeof value.label === "string" && value.label.trim()
? value.label.trim()
: fallbackLabel(value.command);
// "Apply to all hosts" is a portable scope; when set it wins over group scope.
const targetsAllHosts = value.targetsAllHosts === true;
return {
label,
command: value.command,
@@ -159,14 +141,11 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
? uniqueStrings(value.tags)
: [],
package: typeof value.package === "string" ? value.package.trim() : "",
targetGroups: targetsAllHosts
? undefined
: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
targetsAllHosts: targetsAllHosts || undefined,
targetGroups: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
shortkey: typeof value.shortkey === "string" && value.shortkey.trim()
? value.shortkey.trim()
: undefined,
@@ -195,11 +174,7 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
if (parsed.kind !== SNIPPET_EXPORT_KIND) {
throw new Error("Unsupported snippet import file.");
}
if (
version !== SNIPPET_EXPORT_VERSION &&
version !== SNIPPET_EXPORT_VERSION_PREVIOUS &&
version !== SNIPPET_EXPORT_VERSION_LEGACY
) {
if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) {
throw new Error("Unsupported snippet import file.");
}
if (!Array.isArray(parsed.snippets)) {
@@ -212,12 +187,7 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
return {
kind: SNIPPET_EXPORT_KIND,
version:
version === SNIPPET_EXPORT_VERSION
? SNIPPET_EXPORT_VERSION
: version === SNIPPET_EXPORT_VERSION_PREVIOUS
? SNIPPET_EXPORT_VERSION_PREVIOUS
: SNIPPET_EXPORT_VERSION_LEGACY,
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
snippetPackages: collectSnippetPackagePaths(
snippets,
@@ -274,13 +244,8 @@ const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number):
command: item.command,
tags: item.tags || [],
package: item.package || "",
// Host-id bindings are never imported; the all-hosts flag wins over group scope.
targets: [],
...(item.targetsAllHosts
? { targetsAllHosts: true }
: item.targetGroups !== undefined
? { targetGroups: [...item.targetGroups] }
: {}),
...(item.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}),
...(item.shortkey ? { shortkey: item.shortkey } : {}),
...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}),
...(item.multiLineRunMode ? { multiLineRunMode: item.multiLineRunMode } : {}),
-27
View File
@@ -313,12 +313,6 @@ export interface SyncPayload {
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
/**
* Present once the sender has run the autoCloseOnExit default migration.
* Legacy payloads omit it; a materialized `true` in those payloads is the
* old default, not an explicit opt-in.
*/
autoCloseOnExitDefaultMigrated?: boolean;
terminalSidePanelAutoOpen?: boolean;
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen?: boolean;
@@ -396,27 +390,6 @@ export interface SyncPayload {
convergentSync?: ConvergentSyncEnvelopeV2;
}
/**
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
* false. A payload that records the migration keeps an explicit true.
*/
export function reconcileInboundAutoCloseOnExit(
autoCloseOnExit: unknown,
payloadRecordsMigratedChoice: unknown,
): unknown {
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
return autoCloseOnExit;
}
/** True when this side both migrated and explicitly enabled auto-close. */
export function payloadOptsIntoAutoClose(
settings: SyncPayload['settings'] | undefined,
): boolean {
return settings?.autoCloseOnExitDefaultMigrated === true
&& settings.terminalSettings?.autoCloseOnExit === true;
}
/**
* Strip device-local connection telemetry from hosts before any sync
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
-41
View File
@@ -631,44 +631,3 @@ test("group notes sync edits and clearing without dropping connection settings",
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
});
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
},
}),
payload({
settings: {
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
},
}),
).payload.settings;
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
assert.equal(merged?.terminalSettings?.scrollback, 3000);
});
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false },
},
}),
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: true },
},
}),
).payload.settings;
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
});
-19
View File
@@ -21,7 +21,6 @@
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import {
payloadOptsIntoAutoClose,
sanitizeHostsForSync,
type CloudSyncPayloadEntityKey,
type SyncPayload,
@@ -404,24 +403,6 @@ function mergeSettings(
}
}
// A three-way merge can keep this device's migration flag while adopting a
// legacy remote autoCloseOnExit: true. That true is the old default unless
// one of the sides explicitly opted in after migrating.
const terminalSettings = merged.terminalSettings;
if (
terminalSettings
&& typeof terminalSettings === 'object'
&& !Array.isArray(terminalSettings)
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
&& !payloadOptsIntoAutoClose(local)
&& !payloadOptsIntoAutoClose(remote)
) {
merged.terminalSettings = {
...(terminalSettings as Record<string, unknown>),
autoCloseOnExit: false,
};
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
+4 -4
View File
@@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => {
assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false);
});
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
test("normalizeTerminalSettings enables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
});
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
});
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
});
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
+2 -16
View File
@@ -58,25 +58,11 @@ function subscribeToPtyData(ptyStream, onData) {
throw new Error("PTY stream does not support data subscriptions");
}
// Only the end of the output can hold the idle prompt, and this check runs for
// every PTY chunk while the retained output may be hundreds of thousands of
// characters long. Normalising the whole text (ANSI and carriage returns) on
// each chunk stalls the worker during a flood, so look at a tail window that is
// comfortably larger than the prompt including the escape codes it may carry.
const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048;
const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
PROMPT_SUFFIX_MIN_WINDOW_CHARS,
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -1,52 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
function referenceHasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# ";
const CASES = [
["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "],
["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "],
["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "],
["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT],
["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "],
["empty text", "", "root@3160T:~# "],
["empty expected prompt", "root@3160T:~# ", ""],
["null inputs", null, null],
["text shorter than the prompt", "~# ", "root@3160T:~# "],
];
for (const [name, text, prompt] of CASES) {
test(`prompt suffix matches the full-text result: ${name}`, () => {
assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt));
});
}
test("prompt suffix is found after a very large flood of CRLF output", () => {
const flood = "y\r\n".repeat(700_000);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false);
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
const flood = "y\r\n".repeat(700_000);
const started = process.hrtime.bigint();
for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# ");
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`);
});
+4 -182
View File
@@ -3,9 +3,7 @@
*
* Log files are stored as JSONL (one JSON object per line) under
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
* atomic. Files older than 30 days are pruned on startup and every file is
* capped at 16 MB: once today's file is full, further entries are dropped,
* and oversized files left by previous runs are trimmed to their tail.
* atomic. Files older than 30 days are pruned on startup.
*/
const fs = require("node:fs");
@@ -26,15 +24,6 @@ let electronShell = null;
let sessionsMap = null;
const LOG_RETENTION_DAYS = 30;
// Hard caps so a recurring error can never fill the disk (see issue #3626:
// a single crash-YYYY-MM-DD.log grew to ~150 GB). The per-day file stops
// accepting entries once it reaches MAX_LOG_FILE_BYTES and existing oversized
// files are trimmed down to their tail at startup.
const MAX_LOG_FILE_BYTES = 16 * 1024 * 1024;
// Cap the size of a single serialized entry before it is appended.
const MAX_LOG_ENTRY_BYTES = 128 * 1024;
const MAX_MESSAGE_CHARS = 4096;
const MAX_STACK_CHARS = 16384;
const TERMINAL_PERF_DEBUG_ENV_KEYS = [
"NETCATTY_TERMINAL_PERF_DEBUG",
"NETCATTY_TERMINAL_DEBUG",
@@ -85,11 +74,6 @@ function todayFileName() {
return `crash-${ymd}.log`;
}
function boundedText(text, maxChars) {
if (typeof text !== "string" || text.length <= maxChars) return text;
return `${text.slice(0, maxChars)} ... [truncated]`;
}
function buildEntry(source, err, extra) {
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
@@ -116,8 +100,8 @@ function buildEntry(source, err, extra) {
return {
timestamp: new Date().toISOString(),
source,
message: boundedText(error.message || String(err), MAX_MESSAGE_CHARS),
stack: boundedText(error.stack, MAX_STACK_CHARS) || undefined,
message: error.message || String(err),
stack: error.stack || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
@@ -136,70 +120,6 @@ function buildEntry(source, err, extra) {
// Public API
// ---------------------------------------------------------------------------
/**
* Append one JSONL line to filePath as long as the file stays under
* MAX_LOG_FILE_BYTES. Oversized entries are rejected; once a day's file is
* full, later entries are dropped instead of growing the file further.
* Returns true when the line was written.
*/
function appendEntryLine(filePath, line) {
const lineBytes = Buffer.byteLength(line, "utf-8");
if (lineBytes + 1 > MAX_LOG_ENTRY_BYTES) return false;
let size = 0;
try {
size = fs.statSync(filePath).size;
} catch {
size = 0; // file does not exist yet
}
if (size + lineBytes + 1 > MAX_LOG_FILE_BYTES) return false;
try {
fs.appendFileSync(filePath, line + "\n", "utf-8");
return true;
} catch {
return false;
}
}
/**
* True when `line` (plus its trailing newline) fits inside the per-entry byte
* cap. JSON.stringify measures UTF-16 code units, but the file stores UTF-8
* bytes, so multibyte text must be measured with Buffer.byteLength here to
* match what appendEntryLine actually enforces.
*/
function fitsEntryByteBudget(line) {
return Buffer.byteLength(line, "utf-8") + 1 <= MAX_LOG_ENTRY_BYTES;
}
/**
* Serialize a crash entry into a bounded JSONL line. If the caller-supplied
* extra payload (or anything else) blows past MAX_LOG_ENTRY_BYTES, retry with
* progressively less detail so the line length is always bounded.
*/
function serializeEntry(entry) {
let line = JSON.stringify(entry);
if (fitsEntryByteBudget(line)) return line;
const leanEntry = {
...entry,
message: boundedText(entry.message, 1024),
stack: boundedText(entry.stack, MAX_STACK_CHARS),
extra: undefined,
};
line = JSON.stringify(leanEntry);
if (fitsEntryByteBudget(line)) return line;
const minimalEntry = {
timestamp: entry.timestamp,
source: boundedText(entry.source, 256),
message: boundedText(entry.message, 512),
pid: entry.pid,
version: entry.version,
};
return JSON.stringify(minimalEntry);
}
/**
* Write a crash/error entry to today's log file (sync, safe for use in
* uncaughtException handlers).
@@ -210,9 +130,8 @@ function captureError(source, err, extra) {
if (!dir) return;
const entry = buildEntry(source, err, extra);
const line = serializeEntry(entry);
const filePath = path.join(dir, todayFileName());
appendEntryLine(filePath, line);
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
} catch {
// Never throw from the crash logger itself.
}
@@ -255,89 +174,6 @@ function pruneOldLogs() {
}
}
/**
* Rewrite an oversized log file in place so only its last MAX_LOG_FILE_BYTES
* (aligned to a line boundary so every remaining line parses) is kept. This
* reads and writes at most MAX_LOG_FILE_BYTES, so it is safe even for a
* multi-gigabyte file. Returns true when the file was trimmed.
*/
function truncateFileToTail(filePath, maxBytes) {
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= maxBytes) return false;
let fd;
try {
fd = fs.openSync(filePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
// Validate the opened object before reading or truncating it. O_NOFOLLOW
// is not available on every platform, and the path may have changed.
const openedStat = fs.fstatSync(fd);
if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
return false;
}
if (openedStat.size <= maxBytes) return false;
const buf = Buffer.alloc(maxBytes);
const read = fs.readSync(fd, buf, 0, buf.length, openedStat.size - buf.length);
const tail = buf.subarray(0, read);
// Drop the first (possibly partial) line of the tail so every remaining
// line is a complete JSONL entry. Also trim anything after the last
// newline: a final write interrupted before its trailing newline would
// otherwise leave a partial object that the next appendEntryLine appends
// to, corrupting both lines. If the tail contains no newline at all it
// is one giant partial entry (e.g. from an interrupted write), so
// discard it entirely rather than keeping an unappendable file: the file
// would sit exactly at the cap and reject every later report for the day.
const newline = tail.indexOf("\n");
let keep = Buffer.alloc(0);
if (newline >= 0) {
const lastNewline = tail.lastIndexOf("\n");
keep = tail.subarray(newline + 1, lastNewline + 1);
}
fs.ftruncateSync(fd, keep.length);
if (keep.length > 0) {
fs.writeSync(fd, keep, 0, keep.length, 0);
}
return true;
} catch {
return false;
} finally {
try {
if (fd !== undefined) fs.closeSync(fd);
} catch {
// ignore
}
}
}
/**
* Cap every existing crash log file at MAX_LOG_FILE_BYTES. Runs at startup
* before any new entries are appended, so a bloated file from a previous run
* (e.g. issue #3626) is shrunk instead of filling the disk for another day.
*/
function trimOversizedLogs() {
try {
const dir = ensureLogDir();
if (!dir) return;
for (const file of fs.readdirSync(dir)) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
if (truncateFileToTail(path.join(dir, file), MAX_LOG_FILE_BYTES)) {
console.log(`[CrashLog] Trimmed oversized log: ${file}`);
}
} catch {
// skip
}
}
} catch {
// skip
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
@@ -495,7 +331,6 @@ function init(deps) {
// ignore
}
pruneOldLogs();
trimOversizedLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
@@ -524,22 +359,9 @@ function registerHandlers(ipcMain) {
});
}
// Internals exposed for tests only; not part of the public bridge API.
const __internals = {
MAX_LOG_FILE_BYTES,
MAX_LOG_ENTRY_BYTES,
MAX_MESSAGE_CHARS,
MAX_STACK_CHARS,
appendEntryLine,
boundedText,
serializeEntry,
truncateFileToTail,
};
module.exports = {
init,
captureError,
captureDiagnostic,
registerHandlers,
__internals,
};
-162
View File
@@ -1,162 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
MAX_LOG_FILE_BYTES,
MAX_LOG_ENTRY_BYTES,
appendEntryLine,
boundedText,
serializeEntry,
truncateFileToTail,
} = require("./crashLogBridge.cjs").__internals;
// Creates a unique temp directory for one test and registers teardown on the
// test context so fixture files never outlive the run.
function tmpFile(t, name = "crash-2026-10-10.log") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-crashlog-"));
t.after(() => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best effort cleanup
}
});
return { dir, filePath: path.join(dir, name) };
}
function entryLine(padLen = 0) {
return JSON.stringify({
timestamp: "2026-10-10T00:00:00.000Z",
source: "test",
message: "boom",
...(padLen > 0 ? { pad: "p".repeat(padLen) } : {}),
});
}
test("appendEntryLine stops accepting entries once the per-day file reaches the size cap", (t) => {
const { filePath } = tmpFile(t);
assert.equal(appendEntryLine(filePath, entryLine()), true, "first entry written");
assert.equal(
appendEntryLine(filePath, "x".repeat(MAX_LOG_ENTRY_BYTES)),
false,
"oversized entry rejected",
);
// Model the boundary with a small real file, without allocating a full log.
const filler = JSON.stringify({ message: "\u4e2d" });
const lineBytes = Buffer.byteLength(filler, "utf-8");
const before = fs.readFileSync(filePath, "utf-8");
const stat = t.mock.method(fs, "statSync", () => ({ size: MAX_LOG_FILE_BYTES - lineBytes }));
assert.equal(appendEntryLine(filePath, filler), false, "newline would exceed cap by one byte");
assert.equal(fs.readFileSync(filePath, "utf-8"), before);
stat.mock.mockImplementation(() => ({ size: MAX_LOG_FILE_BYTES - lineBytes - 1 }));
assert.equal(appendEntryLine(filePath, filler), true, "entry plus newline fits exactly");
assert.equal(fs.readFileSync(filePath, "utf-8"), before + filler + "\n");
});
test("serializeEntry keeps serialized lines bounded even for huge extras", () => {
const huge = { blob: "z".repeat(MAX_LOG_ENTRY_BYTES + 1) };
const line = serializeEntry({
timestamp: "2026-10-10T00:00:00.000Z",
source: "test",
message: "boom",
stack: new Error("boom").stack,
extra: huge,
});
assert.ok(line.length <= MAX_LOG_ENTRY_BYTES, `line length ${line.length} exceeds cap`);
const entry = JSON.parse(line);
assert.equal(entry.source, "test");
assert.equal(entry.message, "boom");
});
test("boundedText truncates long strings and leaves short ones alone", () => {
assert.equal(boundedText("short", 100), "short");
const cut = boundedText("a".repeat(5000), 4096);
assert.equal(cut.length, 4096 + " ... [truncated]".length);
assert.ok(cut.startsWith("a".repeat(4096)));
assert.ok(cut.endsWith("[truncated]"));
});
test("truncateFileToTail trims oversized files to their last lines", (t) => {
const { filePath } = tmpFile(t);
const maxBytes = 1024;
const line = entryLine(128);
const content = (line + "\n").repeat(8);
fs.writeFileSync(filePath, content);
assert.ok(fs.statSync(filePath).size > maxBytes);
assert.equal(truncateFileToTail(filePath, maxBytes), true);
const newSize = fs.statSync(filePath).size;
assert.ok(newSize <= maxBytes, `trimmed size ${newSize} exceeds cap`);
assert.equal(appendEntryLine(filePath, entryLine()), true, "trimmed log remains appendable");
// Every remaining line must be a complete, parseable JSONL entry.
const remaining = fs.readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
assert.ok(remaining.length > 0);
for (const l of remaining) assert.doesNotThrow(() => JSON.parse(l));
// Files under the cap are left untouched.
assert.equal(truncateFileToTail(filePath, maxBytes), false);
});
test("truncateFileToTail tolerates a tail with no newline and missing files", (t) => {
const { dir, filePath } = tmpFile(t);
// Missing file
assert.equal(truncateFileToTail(path.join(dir, "crash-2026-01-01.log"), MAX_LOG_FILE_BYTES), false);
// Discard an oversized unterminated line so later entries can be appended.
fs.appendFileSync(filePath, "g".repeat(8192));
assert.equal(truncateFileToTail(filePath, 4096), true);
assert.equal(fs.statSync(filePath).size, 0);
// Oversized tail whose final write was interrupted before its trailing
// newline: three terminated entries followed by one entry whose trailing
// newline was never written. The partial final line must be dropped so the
// rewritten file ends on a complete JSONL boundary and the next append does
// not merge with a partial object.
fs.appendFileSync(
filePath,
Array.from({ length: 3 }, () => entryLine(1200) + "\n").join("") +
entryLine(1200),
);
assert.ok(fs.statSync(filePath).size > 4096);
assert.equal(truncateFileToTail(filePath, 4096), true);
const trimmed = fs.readFileSync(filePath, "utf-8");
assert.ok(trimmed.endsWith("\n"), "trimmed file must end on a line boundary");
for (const l of trimmed.split("\n").filter(Boolean)) {
assert.doesNotThrow(() => JSON.parse(l));
}
});
test("truncateFileToTail leaves symlink targets and non-regular files untouched", (t) => {
const { dir, filePath } = tmpFile(t);
const target = path.join(dir, "unrelated.txt");
const content = (entryLine() + "\n").repeat(4);
fs.writeFileSync(target, content);
fs.symlinkSync(target, filePath);
assert.equal(truncateFileToTail(filePath, 128), false);
assert.equal(fs.readFileSync(target, "utf-8"), content);
assert.ok(fs.lstatSync(filePath).isSymbolicLink());
assert.equal(truncateFileToTail(dir, 1), false);
});
test("truncateFileToTail verifies the opened file before modifying it", (t) => {
const { dir, filePath } = tmpFile(t);
const replacement = path.join(dir, "replacement.log");
const content = (entryLine() + "\n").repeat(4);
fs.writeFileSync(filePath, content);
fs.writeFileSync(replacement, content);
const openSync = fs.openSync;
const open = t.mock.method(fs, "openSync", (_filePath, flags) => openSync(replacement, flags));
assert.equal(truncateFileToTail(filePath, 128), false);
open.mock.restore();
assert.equal(fs.readFileSync(filePath, "utf-8"), content);
assert.equal(fs.readFileSync(replacement, "utf-8"), content);
});
@@ -1,10 +1,6 @@
"use strict";
// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB
// operations, deployments); a 30-minute silent close destroyed more in-flight
// work than it saved. 8h covers a workday without sitting on the 24h clamp
// maximum. Users can still set a shorter window in Settings -> AI.
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
@@ -1,193 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { Server } = require("ssh2");
const bridge = require("./sshBridge.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const listen = (server, ...args) => new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(...args, resolve);
});
const close = (server) => new Promise((resolve) => server.close(resolve));
test("normal SSH startup preserves fallback after an unavailable system agent", {
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
timeout: 20_000,
}, async (t) => {
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
const originalHomedir = os.homedir;
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
os.homedir = () => fixture; // Never discover the developer's default keys.
t.after(() => {
os.homedir = originalHomedir;
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
fs.rmSync(fixture, { recursive: true, force: true });
});
const hostKey = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
let sequence = 0;
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
const senderId = 3640 + sequence++;
const socketPath = path.join(fixture, "s");
fs.rmSync(socketPath, { force: true });
const agentClients = new Set();
let agentChecks = 0;
const agent = net.createServer((socket) => {
agentClients.add(socket);
agentChecks += 1;
socket.on("error", () => {});
socket.on("close", () => agentClients.delete(socket));
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
});
await listen(agent, socketPath);
if (stale) {
// Preserve a real socket entry with no listener, without a timing race.
fs.renameSync(socketPath, socketPath + ".stale");
await close(agent);
fs.renameSync(socketPath + ".stale", socketPath);
assert.ok(fs.statSync(socketPath).isSocket());
}
process.env.SSH_AUTH_SOCK = socketPath;
const sshClients = new Set();
const forwardingSockets = new Set();
const methods = [];
let shells = 0;
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
sshClients.add(client);
client.on("error", () => {});
client.on("close", () => sshClients.delete(client));
client.on("tcpip", (accept, reject, info) => {
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
const socket = net.connect(info.destPort, info.destIP, () => {
const stream = accept();
stream.on("error", () => socket.destroy());
stream.pipe(socket).pipe(stream);
});
forwardingSockets.add(socket);
socket.on("close", () => forwardingSockets.delete(socket));
socket.on("error", () => reject());
});
client.on("authentication", (ctx) => {
methods.push(ctx.method);
if (ctx.method === "password" && stall) return;
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
else ctx.reject(["publickey", "password"]);
});
client.on("ready", () => client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty());
session.on("shell", (acceptShell) => {
shells += 1;
acceptShell().write("fixture ready\r\n");
});
}));
});
await listen(ssh, 0, "127.0.0.1");
const handlers = new Map();
const sent = [];
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
const event = { sender: { id: senderId, isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }) } };
let guard;
try {
const start = handlers.get("netcatty:start")(event, {
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
username: "fixture", password, authMethod: "auto", useSshAgent,
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
...(jump ? { jumpHosts: [{
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
password, authMethod: "auto", useSshAgent,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
}] } : {}),
});
let error;
try {
await Promise.race([start, new Promise((_, reject) => {
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
})]);
} catch (err) { error = err; }
return { error, methods, shells, sent: sent.slice(), agentChecks };
} finally {
clearTimeout(guard);
for (const client of sshClients) client.end();
for (const socket of forwardingSockets) socket.destroy();
for (const client of agentClients) client.destroy();
if (agent.listening) await close(agent);
await close(ssh);
}
}
for (const [name, options] of [
["stale agent", {}],
["disabled agent", { useSshAgent: false }],
["healthy empty agent", { stale: false }],
]) {
await t.test(`${name} still opens a real shell using the saved password`, async () => {
const result = await run(options);
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
});
}
for (const [name, password, methods] of [
["agent without other credentials", undefined, ["none"]],
["incorrect password", "wrong-password", ["none", "password"]],
]) {
await t.test(`${name} still reports a real authentication failure`, async () => {
// null deliberately omits a password rather than taking run's default.
const result = await run({ password: password ?? null });
assert.equal(result.error?.level, "client-authentication");
assert.match(result.error.message, /All configured authentication methods failed/);
assert.deepEqual(result.methods, methods);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
});
}
await t.test("stalled password fallback retains its authentication deadline", async () => {
const result = await run({ stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel, payload }) => (
channel === "netcatty:exit" && payload.reason === "timeout"
)));
});
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
const result = await run({ jump: true });
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
});
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
const result = await run({ jump: true, password: "wrong-password" });
assert.equal(result.error?.level, "client-authentication");
assert.equal(result.error?.isJumpHostAuthError, true);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
const result = await run({ jump: true, stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
});
+1 -6
View File
@@ -813,12 +813,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}
resolve();
});
conn.on('error', (err) => {
// ssh2 continues to the next auth method after agent errors.
if (err.level === 'agent') {
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
conn.once('error', (err) => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
@@ -2415,90 +2415,7 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGUSR2");
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
const session = sessions.get("signal-then-conn-close");
// ssh2 can emit conn "close" before the channel's paced finalize. The
// signal recorded on "exit" must still win over a generic transport close.
session.stream.emit("exit", null, "SIGHUP");
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGHUP");
assert.equal(exits[0].exitCode, undefined);
});
test("a connection close without an exit-signal stays a transport close", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
sessions.get("transport-conn-close").conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
assert.equal(exits[0].exitSignal, undefined);
});
for (const exitCode of [0, 7]) {
test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const sessionId = `status-then-conn-close-${exitCode}`;
await start({ sender }, {
hostname: "10.0.0.50", username: "alice", port: 22,
authMethod: "password", password: "secret",
useSshAgent: false, verifyHostKeys: false, sessionId,
});
const session = sessions.get(sessionId);
session.stream.emit("exit", exitCode);
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "exited");
assert.equal(exits[0].exitCode, exitCode);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
});
}
@@ -1,57 +0,0 @@
/**
* Classify how a remote SSH shell channel ended.
*
* An exit-signal (ssh2: code null, signal set) is an administrative
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
* Those stay "signaled" so auto-reconnect can leave them alone.
* A channel that closes with neither exit-status nor exit-signal stays
* "closed" and is still eligible to reconnect.
*/
function normalizeRemoteExitSignal(signal) {
return typeof signal === "string" && signal.length > 0 ? signal : null;
}
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
if (idleTimedOut) return "timeout";
const signal = normalizeRemoteExitSignal(exitSignal);
if (signal) return "signaled";
if (shellExited) return "exited";
return "closed";
}
function buildRemoteShellExitPayload({
sessionId,
generation,
exitCode,
exitSignal,
shellExited,
idleTimedOut,
}) {
const signal = normalizeRemoteExitSignal(exitSignal);
const reason = classifyRemoteShellClose({
idleTimedOut: idleTimedOut === true,
exitSignal: signal,
shellExited: shellExited === true,
});
const payload = {
sessionId,
reason,
_terminalSessionGeneration: generation,
};
if (typeof exitCode === "number") {
payload.exitCode = exitCode;
} else if (!signal) {
payload.remoteClosedWithoutExitStatus = true;
}
if (signal) {
payload.exitSignal = signal;
}
return payload;
}
module.exports = {
normalizeRemoteExitSignal,
classifyRemoteShellClose,
buildRemoteShellExitPayload,
};
@@ -1,73 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildRemoteShellExitPayload,
classifyRemoteShellClose,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
assert.equal(normalizeRemoteExitSignal(""), null);
assert.equal(normalizeRemoteExitSignal(null), null);
assert.equal(normalizeRemoteExitSignal(undefined), null);
});
test("an exit-signal is an administrative signaled close, not a transport close", () => {
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
"signaled",
);
}
});
test("a channel close without exit-status or exit-signal stays closed", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
"closed",
);
});
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
"exited",
);
assert.equal(
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
"timeout",
);
});
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "signal-exit",
generation: 3,
exitSignal: "SIGUSR2",
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "signaled");
assert.equal(payload.exitSignal, "SIGUSR2");
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
assert.equal(payload._terminalSessionGeneration, 3);
});
test("a bare channel-close payload stays a reconnectable transport close", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "transport-close",
exitSignal: null,
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "closed");
assert.equal(payload.exitSignal, undefined);
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, true);
});
+1 -1
View File
@@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) {
disks, // Array of all mounted disks
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -746,46 +746,6 @@ test("getServerStats measures latency by pinging the stats connection", async ()
assert.deepEqual(pingedConns, [session.moshStatsConn]);
});
test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => {
const net = require("node:net");
const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs");
let accepted = 0;
const server = net.createServer((socket) => {
accepted += 1;
socket.destroy();
});
t.after(() => new Promise((resolve) => server.close(() => resolve())));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const conn = Object.assign(fakeConn(LINUX_STATS), {
config: { keepaliveInterval: 30000 },
});
const sessions = new Map([["sid", {
type: "ssh",
conn,
hostname: "127.0.0.1",
port,
_reuseEndpoint: { hostname: "127.0.0.1", port },
}]]);
let pingTarget = null;
const measureSshPingLatency = createSshPingLatencyProbe();
const api = makeSessionOps(sessions, {
measureSshPingLatency: (target, timeoutMs) => {
pingTarget = target;
return measureSshPingLatency(target, timeoutMs);
},
});
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(result.success, true);
assert.equal(pingTarget, conn);
assert.equal(result.stats.latencyMs, null);
assert.equal(accepted, 0);
});
test("getServerStats pings the companion connection for jump-host sessions", async () => {
const sessions = new Map([["sid", {
type: "mosh",
+26 -68
View File
@@ -27,10 +27,6 @@ const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
} = require("../macLocalNetworkAccess.cjs");
const {
buildRemoteShellExitPayload,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
@@ -548,15 +544,6 @@ function createStartSessionApi(ctx) {
),
});
},
// Upload progress tracks bytes the transport actually accepted instead
// of bytes queued (issue #3663): ssh2 keeps writableLength unchanged
// until one queued write fully completes but shrinks _chunk as the
// channel window opens, so summing both approximates the outstanding
// backlog while it drains. Without this, an rz progress bar freezes
// mid-file for as long as the channel window takes to flush.
getTransportPendingBytes() {
return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0);
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
@@ -678,8 +665,7 @@ function createStartSessionApi(ctx) {
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits
// and are reported as "signaled", not the reconnectable "closed".
// timeout) have code=null and signal set — those are not user exits.
// A third case exists: a remote that closes the channel without sending
// exit-status or exit-signal never fires "exit" at all (#3591). The
// initial values below stay null in that case and must not be reported
@@ -690,17 +676,8 @@ function createStartSessionApi(ctx) {
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = normalizeRemoteExitSignal(signal);
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExited = typeof code === "number" && !streamExitSignal;
// conn "close" can win the race against this channel's paced
// finalize. Stamp the signal so that path can still tell an
// administrative exit-signal from a transport drop.
if (streamExitSignal && sessions.get(sessionId) === session) {
session._remoteExitSignal = streamExitSignal;
}
if (streamExited && sessions.get(sessionId) === session) {
session._remoteExitCode = streamExitCode;
}
});
let closeFinalized = false;
@@ -709,7 +686,6 @@ function createStartSessionApi(ctx) {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExitSignal,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
@@ -741,20 +717,27 @@ function createStartSessionApi(ctx) {
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
// is reported as "timeout" via the shell banner (#1062 / #977).
// An exit-signal is a separate administrative kick ("signaled").
// A channel close with neither exit-status nor exit-signal stays
// "closed" and must not be reported as code 0 (#3591).
// A shell TMOUT auto-logout is a clean exit (numeric code, no
// signal) — identical to a user-typed `exit` by code/signal —
// so detect it via the banner the shell prints just before
// exiting and report it as a timeout. That keeps the tab open
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const exitPayload = buildRemoteShellExitPayload({
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
const exitPayload = {
sessionId,
generation: liveSession?._terminalSessionGeneration,
exitCode: streamExitCode,
exitSignal: streamExitSignal,
shellExited: streamExited,
idleTimedOut,
});
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
};
// Report only exit facts the remote actually sent (#3591). When
// the peer closed the channel without exit-status/exit-signal,
// there is no code to show — flag it instead so the renderer can
// distinguish an abnormal teardown from a user-typed `exit 0`.
if (typeof streamExitCode === "number") {
exitPayload.exitCode = streamExitCode;
} else if (streamExitSignal === null) {
exitPayload.remoteClosedWithoutExitStatus = true;
}
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
@@ -2481,16 +2464,6 @@ function createStartSessionApi(ctx) {
});
conn.on("error", (err) => {
// ssh2 reports agent failures before trying the next auth method.
// Keep the connection and its authentication deadline alive.
if (err.level === "agent") {
log("non-fatal agent auth error (will try next method)", {
sessionId,
hostname: options.hostname,
error: err.message,
});
return;
}
clearAuthReadyTimer();
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
@@ -2665,28 +2638,13 @@ function createStartSessionApi(ctx) {
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
// The shell already reported an exit-signal. This connection
// close is the server finishing that administrative kick, not
// a transport flap — keep it out of the reconnect loop.
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
sessionId,
generation: session?._terminalSessionGeneration,
exitSignal: session._remoteExitSignal,
shellExited: false,
idleTimedOut: false,
}));
} else {
// Preserve a shell status already received before transport
// close; otherwise do not invent a successful exit code.
const shellExited = typeof session?._remoteExitCode === "number";
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
generation: session?._terminalSessionGeneration,
exitCode: session?._remoteExitCode,
shellExited,
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
}));
exitCode: 0,
reason: "closed",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
}
// Use this connection's captured token so a late close from an
// old transport can't stop a newer same-sessionId stream (#916).
+7 -14
View File
@@ -832,19 +832,13 @@ main();
try {
// Resolve the real OpenSSH binary to an absolute path BEFORE we
// prepend the wrapper directory to PATH. Embedding a bare `ssh`
// name would recurse into the wrapper forever. findExecutable
// returns that bare name off Windows, so only an absolute result
// is usable; otherwise fall back to command -v and well-known paths.
let realSsh = "";
try {
const found = typeof findExecutable === "function" ? findExecutable("ssh") : "";
if (found && path.isAbsolute(String(found))) realSsh = String(found);
} catch {
// Fall through to command -v / well-known paths.
}
// name would recurse into the wrapper forever.
let realSsh = process.platform === "win32"
? (findExecutable("ssh") || "")
: "";
if (!realSsh || !path.isAbsolute(realSsh)) {
try {
const lookup = execFileSync(
const resolved = execFileSync(
process.platform === "win32" ? "where" : "sh",
process.platform === "win32" ? ["ssh"] : ["-c", "command -v ssh"],
{
@@ -853,9 +847,8 @@ main();
windowsHide: true,
env: process.env,
},
);
const resolved = String(lookup ?? "").trim().split(/\r?\n/).find(Boolean) || "";
if (resolved && path.isAbsolute(resolved)) realSsh = resolved;
).trim().split(/\r?\n/)[0];
if (resolved) realSsh = resolved;
} catch {
// Fall through to common absolute locations.
}
@@ -26,13 +26,6 @@ function makeApi(t, overrides = {}) {
getTempFilePath: (name) => path.join(base, name),
getTempDir: () => base,
};
// Absolute stand-in for OpenSSH. prepareEtSshEnvironment must be able to
// inject the PATH wrapper without the host image having /usr/bin/ssh, and
// without the execFileSync stub (used to skip Windows icacls) answering
// `command -v ssh`.
const fakeSsh = path.join(base, "openssh", process.platform === "win32" ? "ssh.exe" : "ssh");
fs.mkdirSync(path.dirname(fakeSsh), { recursive: true });
fs.writeFileSync(fakeSsh, "#!/bin/sh\nexit 0\n");
const osMock = {
homedir: () => fakeHome,
userInfo: () => ({ username: "tester" }),
@@ -60,7 +53,7 @@ function makeApi(t, overrides = {}) {
trackSessionIdlePrompt: () => {},
createPtyOutputBuffer: overrides.createPtyOutputBuffer
|| (() => ({ bufferData() {}, flush() {}, flushPaced() {} })),
findExecutable: (name) => (name === "ssh" ? fakeSsh : name),
findExecutable: () => "ssh",
bundledEtClient,
});
return { api, base, sessions };
-3
View File
@@ -76,9 +76,6 @@ const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
// Minimum window size: enough to render the expanded sidebar + a usable
// host list + the 420px host details / new-host aside panel without overflow.
// Small displays shrink this fit to their work area at window-creation time
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
// the minimum tracks the display the window currently occupies.
const MIN_WINDOW_WIDTH = 1100;
const MIN_WINDOW_HEIGHT = 640;
+4 -136
View File
@@ -6,104 +6,6 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
// logical px), which made those windows impossible to fit — let alone shrink.
// Keep the layout minimum on capable screens, but shrink it to the work area
// of the display the window targets so the window can always fit with a small
// margin.
const MIN_SIZE_FIT_FACTOR = 0.9;
const MIN_WINDOW_FLOOR_WIDTH = 320;
const MIN_WINDOW_FLOOR_HEIGHT = 240;
function pickTargetDisplay(screenModule, bounds) {
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
return screenModule?.getPrimaryDisplay?.() || null;
}
try {
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
} catch {
try {
return screenModule.getPrimaryDisplay?.() || null;
} catch {
return null;
}
}
}
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
let effectiveWidth = minWidth;
let effectiveHeight = minHeight;
try {
// Size for the display the window actually lives on (saved position or
// current placement), falling back to the primary display.
const display = bounds
? pickTargetDisplay(screenModule, bounds)
: screenModule?.getPrimaryDisplay?.();
const workArea = display?.workArea;
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
}
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
}
} catch {
// screen unavailable (e.g. before app ready): keep the static minimums
}
return {
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
};
}
// Keep the BrowserWindow minimum in sync with the display it currently lives
// on: docking, unplugging a screen, or display-scaling changes can move the
// window onto a display with a different work area.
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
const applyMinimumForCurrentDisplay = () => {
try {
if (win.isDestroyed?.()) return;
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
if (next.width === currentWidth && next.height === currentHeight) return;
win.setMinimumSize(next.width, next.height);
} catch {
// Best-effort only.
}
};
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
const onWindowMoved = () => applyMinimumForCurrentDisplay();
try {
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
screenModule.on("display-removed", onDisplayRemoved);
win.on("move", onWindowMoved);
} catch {
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
return () => {};
}
return function detachDisplayMinimumSync() {
try {
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
screenModule.removeListener?.("display-removed", onDisplayRemoved);
} catch {
// ignore
}
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
};
}
function setTerminalKeyboardFocusForWindow(win, focused) {
if (!win || win.isDestroyed?.() || !win.webContents) return false;
const isFocused = focused === true;
@@ -211,24 +113,11 @@ function createMainWindowApi(ctx) {
height: DEFAULT_WINDOW_HEIGHT,
};
// Size the minimum to the display the restored window will live on
// (a saved position may target a smaller secondary display), falling
// back to the primary display when there is no usable saved position.
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
: null;
const windowMinimumSize = resolveWindowMinimumSize(
screen,
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
savedTargetBounds,
);
if (savedState) {
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
@@ -260,8 +149,8 @@ function createMainWindowApi(ctx) {
const win = new BrowserWindow({
...windowBounds,
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
backgroundColor,
icon: appIcon,
show: false,
@@ -301,20 +190,6 @@ function createMainWindowApi(ctx) {
detachDisplayRecovery = null;
}
// #3647: docking / display-scaling changes can move the window onto a
// display with a different work area; keep the minimum in sync so the
// window can always fit its current display.
const detachDisplayMinimumSync = attachDisplayMinimumSync({
win,
screen,
// Pass the configured (layout) minimum as the baseline, not the
// display-fitted value resolved above: otherwise a reduced minimum
// would become a permanent ceiling and could never grow back to
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
});
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
@@ -322,11 +197,6 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
try {
detachDisplayMinimumSync?.();
} catch {
// ignore
}
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
@@ -725,8 +595,6 @@ function createMainWindowApi(ctx) {
}
module.exports = {
attachDisplayMinimumSync,
createMainWindowApi,
resolveWindowMinimumSize,
setTerminalKeyboardFocusForWindow,
};
@@ -1,194 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
test("uses the static layout minimums when they fit the display", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 1100, height: 640 });
});
test("sizes the minimum for the display containing the target bounds", () => {
const primary = { workArea: { width: 1920, height: 1080 } };
const secondary = { workArea: { width: 1093, height: 585 } };
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => primary,
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
},
1100,
640,
{ x: 2000, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("falls back to the primary display when the target cannot be matched", () => {
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getDisplayMatching: () => null,
},
1100,
640,
{ x: 0, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("refreshes the window minimum when display metrics change", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
let minimum = { width: 1100, height: 640 };
win.getMinimumSize = () => [minimum.width, minimum.height];
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
try {
// Docking change shrinks the primary work area under the window.
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
// No-op emission keeps the same minimum.
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
} finally {
detach();
}
});
test("ignores display events after detach", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
win.getMinimumSize = () => [1100, 640];
let called = 0;
win.setMinimumSize = () => { called += 1; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
detach();
screenModule.emit("display-metrics-changed");
screenModule.emit("display-removed");
assert.equal(called, 0);
});
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
1100,
640,
);
assert.equal(size.width, Math.floor(1093 * 0.9));
assert.equal(size.height, Math.floor(585 * 0.9));
assert.ok(size.width < 1100 && size.height < 640);
});
test("never shrinks below a small usable floor", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 320, height: 240 });
});
test("keeps the static minimums when screen is unavailable", () => {
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(
resolveWindowMinimumSize(
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
1100,
640,
),
{ width: 1100, height: 640 },
);
});
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
const { EventEmitter } = require("node:events");
const { createMainWindowApi } = require("./mainWindow.cjs");
let constructedOptions = null;
class Window extends EventEmitter {
constructor(options) {
super();
constructedOptions = options;
this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), {
id: 71,
isDestroyed: () => this.destroyed,
setWindowOpenHandler() {},
openDevTools() {},
send() {},
});
}
close() {}
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
});
await api.createWindow(
{
BrowserWindow: Window,
nativeTheme: {},
app: {},
screen: {
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getAllDisplays: () => [],
},
shell: {},
ipcMain: {},
},
{
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: true,
electronDir: __dirname,
},
);
assert.ok(constructedOptions);
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
});
+30 -272
View File
@@ -107,11 +107,6 @@ function buildModeRestores(dir, names, removeIndices, modes) {
* Optional. When writeToRemote returns false, the upload loop awaits this
* before sending more file data (typically waitForWritableDrain(stream)).
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
* @param {() => number} [opts.getTransportPendingBytes]
* Optional. Return the bytes the transport has queued but not delivered yet
* (e.g. the SSH channel backlog). Enables delivered-byte upload progress:
* while parked on backpressure the loop reports queued-minus-outstanding
* bytes so the progress indicator tracks actual delivery (issue #3663).
* @param {() => import('electron').WebContents | null} opts.getWebContents
* Returns the Electron WebContents for sending progress IPC events.
* @param {string} [opts.label]
@@ -136,11 +131,6 @@ function createZmodemSentry(opts) {
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let pendingTerminalSuppressionAt = 0;
let pendingHexHeaderFragment = null;
let pendingHexHeaderFragmentTimer = null;
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
// next terminal-output chunk; at most one byte is then dropped.
let suppressNextTerminalXon = false;
function setPendingTerminalSuppression(buf) {
pendingTerminalSuppression = buf;
@@ -162,16 +152,6 @@ function createZmodemSentry(opts) {
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
// "**" + ZDLE + 'B': the start of every ZMODEM hex header.
const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]);
// 4-byte pattern + 14 hex digits + CR is the most a still-incomplete
// header can look like before the final LF (plus optional XON) arrives.
const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19;
// How long a withheld partial-header fragment may sit unresolved before
// it is flushed through to the terminal anyway.
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
? Math.max(1, opts.hexHeaderFragmentTtlMs)
: 1000;
/**
* Upper bound on how long a drag-drop echo suppression may stay armed.
* The suppression hides the terminal echo of the upload command, whose echo
@@ -260,14 +240,6 @@ function createZmodemSentry(opts) {
return buf;
}
function isHexDigitByte(byte) {
return (
(byte >= 0x30 && byte <= 0x39)
|| (byte >= 0x41 && byte <= 0x46)
|| (byte >= 0x61 && byte <= 0x66)
);
}
function stripVisibleZmodemHeaders(data) {
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let searchFrom = 0;
@@ -281,7 +253,12 @@ function createZmodemSentry(opts) {
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
isHexHeader = false;
break;
}
@@ -297,10 +274,6 @@ function createZmodemSentry(opts) {
headerLength = 20;
if (buf[prefixAt + 20] === 0x11) {
headerLength = 21;
} else if (prefixAt + 20 === buf.length) {
// The optional XON that follows the CR/LF may arrive in the next
// chunk; strip at most one leading XON byte from it (#3651).
suppressNextTerminalXon = true;
}
}
@@ -314,119 +287,6 @@ function createZmodemSentry(opts) {
return buf;
}
function flushPendingHexHeaderFragment() {
pendingHexHeaderFragmentTimer = null;
const buf = pendingHexHeaderFragment;
pendingHexHeaderFragment = null;
if (buf?.length) onData(buf);
}
/**
* A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be
* split across transport chunks. zmodem.js hands each original chunk to
* to_terminal verbatim and only complete headers can be stripped, so a
* chunk that ends in the middle of the preamble painted it as terminal
* garble (#3651). Detect the trailing bytes that may still form one and
* hold them back; the next chunk either completes the header (stripped
* above) or proves the buffer invalid. A TTL guard flushes held bytes so
* deferrals can never lose legitimate terminal output.
*
* Returns the index from which the buffer should be withheld, or -1.
*/
function findTrailingHexHeaderFragment(buf) {
if (!buf.length) return -1;
// Longest still-incomplete header tail: the 4-byte pattern, 14 hex
// digits, and a CR — i.e. everything up to the final LF (+ optional XON).
const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL);
for (let i = windowStart; i <= buf.length - 4; i += 1) {
let matched = true;
for (let j = 0; j < 4; j += 1) {
if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matched = false;
break;
}
}
if (!matched) continue;
let digits = 0;
while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) {
digits += 1;
}
// Chunk ends inside the hex digits — the header may still complete.
if (i + 4 + digits === buf.length) return i;
// Digit run ended before the 14 required digits, or overshoots them:
// not a hex header.
if (digits !== 14) continue;
// A CR (but not yet the trailing LF) may still complete the header.
if (buf.length - i <= 19) return i;
break;
}
// The "**" + ZDLE + 'B' pattern itself may be split: withhold any
// trailing bytes that are a prefix of it until the next chunk resolves.
for (let k = Math.min(3, buf.length); k >= 1; k -= 1) {
let matches = true;
for (let j = 0; j < k; j += 1) {
if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matches = false;
break;
}
}
if (matches) return buf.length - k;
}
return -1;
}
/**
* Join any previously withheld partial-header bytes onto the new chunk so
* the suppression/stripping stages can see a complete header again. This
* must run BEFORE the stripping stages, or a header split across two
* chunks would be judged complete too late and reach the terminal.
*/
function assembleTerminalChunk(octets) {
const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets);
if (!pendingHexHeaderFragment?.length) {
return buf;
}
if (pendingHexHeaderFragmentTimer) {
clearTimeout(pendingHexHeaderFragmentTimer);
pendingHexHeaderFragmentTimer = null;
}
const merged = Buffer.concat([pendingHexHeaderFragment, buf]);
pendingHexHeaderFragment = null;
return merged;
}
/**
* Withhold a trailing partial hex header (if any) and arm a timer that
* flushes it when no more bytes arrive to resolve it.
*/
function deferIncompleteHexHeader(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fragmentAt = findTrailingHexHeaderFragment(buf);
if (fragmentAt === -1) return buf;
if (fragmentAt < buf.length) {
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
if (!pendingHexHeaderFragmentTimer) {
pendingHexHeaderFragmentTimer = setTimeout(
flushPendingHexHeaderFragment,
HEX_HEADER_FRAGMENT_TTL_MS,
);
}
}
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
}
function looksLikeResidualZmodemData(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!buf.length) return true;
@@ -593,19 +453,8 @@ function createZmodemSentry(opts) {
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data – pass raw bytes to the caller for charset-aware decoding.
// Bytes that may be the start of a ZMODEM hex header split across
// chunks are withheld first (and later stripped or flushed) so the
// preamble of sz/rz never reaches the terminal as garble (#3651).
let sanitizedOctets = assembleTerminalChunk(octets);
if (suppressNextTerminalXon) {
suppressNextTerminalXon = false;
if (sanitizedOctets[0] === 0x11) {
sanitizedOctets = sanitizedOctets.subarray(1);
}
}
sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets);
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
@@ -917,8 +766,6 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
const UPLOAD_CHUNK_SIZE = 64 * 1024;
/** Default interval between non-final upload progress IPC events. */
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
/** Interval between delivered-byte progress reports while parked on drain. */
const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100;
function resolveTimeoutMs(value, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
@@ -1134,76 +981,6 @@ function createZmodemUploadDrainWaiter(opts) {
};
}
/**
* Park the upload loop on transport backpressure while keeping progress
* updates alive (issue #3663).
*
* Once a large write backlog fills the transport, the upload loop otherwise
* sits silent on the "drain" event while the transport keeps delivering the
* queued backlog at full link speed: on SSH the entire channel window drains
* between two upload-loop steps, so the progress indicator freezes mid-file
* and jumps when the loop resumes. While the drain wait stays pending, poll
* it and let the caller emit progress events showing bytes the transport has
* actually accepted instead.
*
* Falls back to a plain drain wait when the transport cannot report its
* outstanding byte count. Rejections (timeout, cancellation) propagate.
*
* @param {{
* waitForDrain?: () => Promise<void>,
* getTransportPendingBytes?: () => unknown,
* progressThrottleMs?: number,
* }} opts
* @param {() => void} emitProgress Called at most once per throttle interval
* while the drain wait stays pending.
* @returns {Promise<void>}
*/
async function awaitUploadDrainWithProgress(opts, emitProgress) {
if (!opts.waitForDrain) return;
if (typeof opts.getTransportPendingBytes !== "function") {
await opts.waitForDrain();
return;
}
let settled = false;
const drain = opts.waitForDrain().finally(() => {
settled = true;
});
drain.catch(() => {}); // observed at the final await; never surface early
// A drain wait without pending backpressure resolves after one event-loop
// turn; yield once so backpressure-free uploads do not gain polling delay.
await new Promise((resolve) => setImmediate(resolve));
if (settled) {
await drain;
return;
}
const intervalMs = Math.max(
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
resolveProgressThrottleMs(opts.progressThrottleMs),
);
// Parked: report accepted bytes right away, then once per interval while
// the transport drains its backlog. Progress emissions must never break
// (or slow) the transfer itself.
try { emitProgress(); } catch { /* ignore */ }
// Race each poll delay against drain. A wait that settles mid-interval
// must resume the upload immediately; sleeping out the rest of the
// interval throttles every backpressure cycle by up to ~100ms.
while (!settled) {
let timer;
await Promise.race([
drain.then(() => {}, () => {}),
new Promise((resolve) => {
timer = setTimeout(resolve, intervalMs);
}),
]);
clearTimeout(timer);
if (settled) break;
try { emitProgress(); } catch { /* ignore */ }
}
await drain;
}
/**
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
@@ -1406,42 +1183,19 @@ async function handleUpload(zsession, opts) {
const { originalIndex, filePath, stat, name } = offers[i];
opts.resetUploadBackpressure?.();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
// Progress should track bytes the transport accepted, not just bytes
// queued (#3663): on SSH the channel can buffer its whole window before
// write() returns false, so queued-only reporting freezes the progress
// bar while the backlog drains at link speed. When the transport can
// report outstanding bytes, show `sent - outstanding` and never move
// the bar backwards.
const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function"
? opts.getTransportPendingBytes
: null;
let sent = 0;
let displayedTransferred = 0;
const emitFileProgress = () => {
const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN;
const delivered = Number.isFinite(outstanding)
? sent - Math.max(outstanding, 0)
: sent;
displayedTransferred = Math.min(
Math.max(delivered, displayedTransferred),
stat.size,
);
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: displayedTransferred,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
};
emitFileProgress();
// The offer handshake is the only upload step without a built-in
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
// send_offer() would park this loop forever and block all future
@@ -1469,6 +1223,7 @@ async function handleUpload(zsession, opts) {
// Read and send in chunks
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
let sent = 0;
// Progress IPC is throttled by default for every transport. Pass 0 only
// when a caller explicitly needs one event per chunk.
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
@@ -1488,16 +1243,21 @@ async function handleUpload(zsession, opts) {
const now = Date.now();
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
emitFileProgress();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
lastProgressEmitAt = now;
}
// Wait for transport to drain if its buffer is full (emitting
// delivered-byte progress while parked, #3663), then yield so
// inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) {
await awaitUploadDrainWithProgress(opts, emitFileProgress);
}
// Wait for transport to drain if its buffer is full, then yield
// so inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) await opts.waitForDrain();
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
@@ -1782,8 +1542,6 @@ module.exports = {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
};
-406
View File
@@ -11,7 +11,6 @@ const {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
} = require("./zmodemHelper.cjs");
@@ -423,282 +422,6 @@ test("handleUpload does not read the next chunk until transport backpressure cle
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
const zsession = {
async send_offer() {
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
// 4096 bytes of every queued amount are still outstanding in the transport.
getTransportPendingBytes: () => 4096,
});
assert.deepEqual(
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
[
[0, false],
[UPLOAD_CHUNK_SIZE - 4096, false],
[size - 4096, false],
[size, true],
],
);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload emits delivered-byte progress while parked on transport drain", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2 + 1;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
// Bytes queued into the transport (never consumed; delivery is simulated
// below on the pending-byte readout).
let outstanding = 0;
let drainStartedAt = null;
let releaseFirstDrain;
const firstDrain = new Promise((resolve) => {
releaseFirstDrain = resolve;
});
let drainCalls = 0;
const zsession = {
async send_offer() {
return {
send(chunk) {
outstanding += chunk.byteLength;
},
async end() {},
};
},
async close() {},
};
const upload = handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
getTransportPendingBytes: () => {
if (drainStartedAt === null) return outstanding;
// The backlog drains at 512 B/ms once the parked wait begins.
const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512);
return outstanding - delivered;
},
async waitForDrain() {
drainCalls += 1;
if (drainCalls === 1) {
drainStartedAt = Date.now();
await firstDrain;
}
},
});
while (drainCalls === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
const progressedBefore = progress.length;
await new Promise((resolve) => setTimeout(resolve, 250));
const parkedEvents = progress.slice(progressedBefore);
releaseFirstDrain();
await upload;
// While parked, delivered-byte progress must keep flowing (previously the
// indicator stayed frozen until the whole backlog drained).
assert.ok(
parkedEvents.some((event) => event.transferred > 0 && !event.finalizing),
`expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`,
);
const values = progress.map((event) => event.transferred);
for (let k = 1; k < values.length; k++) {
assert.ok(
values[k] >= values[k - 1],
`progress must never move backwards: ${values.join(",")}`,
);
assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`);
}
assert.equal(progress.at(-1).finalizing, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => {
let drainCalls = 0;
await awaitUploadDrainWithProgress(
{
waitForDrain: () => {
drainCalls += 1;
return Promise.resolve();
},
},
() => {
throw new Error("no progress emissions without pending-byte accounting");
},
);
assert.equal(drainCalls, 1);
});
test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => {
const startedAt = Date.now();
await awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.resolve(),
getTransportPendingBytes: () => 0,
},
() => {
throw new Error("no emissions without parked backpressure");
},
);
assert.ok(Date.now() - startedAt < 50);
});
test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
// The immediate park emission has run and the poll timer (~100ms) is armed.
// Settle on the next turn, still inside that interval.
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
releaseDrain();
await done;
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain settlement during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => {
let rejectDrain;
const drainGate = new Promise((_, reject) => {
rejectDrain = reject;
});
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
rejectDrain(err);
await assert.rejects(done, (caught) => caught === err);
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain rejection during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let displayed = 0;
const emitted = [];
const deliverTimer = setInterval(() => {
displayed += 512;
}, 40);
try {
const parked = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 1024,
},
() => emitted.push(displayed),
);
await new Promise((resolve) => setTimeout(resolve, 280));
assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`);
assert.ok(emitted.every((v) => v === 0 || v % 512 === 0));
releaseDrain();
await parked;
} finally {
clearInterval(deliverTimer);
}
});
test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => {
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
await assert.rejects(
awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.reject(err),
getTransportPendingBytes: () => 0,
},
() => {},
),
(caught) => caught === err,
);
});
test("handleUpload throttles progress IPC by default for every transport", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
const filePath = path.join(tempDir, "large-upload.bin");
@@ -1766,132 +1489,3 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo
await waitForDrain();
assert.equal(needsDrain, false);
});
// ---------------------------------------------------------------------------
// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens
// every sz/rz session can be fragmented across transport chunks; it must
// never reach the terminal as garble, and withheld bytes must never be lost.
// ---------------------------------------------------------------------------
const ZmodemLib = require("zmodem.js");
const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r";
function buildHexHeaderBytes(name, args) {
return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex());
}
function createProbeSentry(extra) {
const terminal = [];
const sentry = createZmodemSentry({
sessionId: "probe",
onData: (buf) => terminal.push(Buffer.from(buf)),
writeToRemote: () => true,
getWebContents: () => null,
selectDownloadDirectory: async () => ({ canceled: true }),
selectUploadFiles: async () => null,
hexHeaderFragmentTtlMs: 30,
...extra,
});
return { sentry, terminal };
}
function joinedText(terminal) {
return Buffer.concat(terminal).toString("latin1");
}
function assertNoZmodemHeaderBytes(terminal, context) {
const joined = Buffer.concat(terminal);
assert.equal(
joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])),
-1,
`${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`,
);
assert.equal(
joined.includes(Buffer.from([0x18])),
false,
`${context}: ZDLE/CAN byte leaked to the terminal`,
);
}
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
buildHexHeaderBytes("ZRQINIT"),
Buffer.from("$ "),
]);
for (const byte of stream) sentry.consume(Buffer.from([byte]));
assert.equal(
joinedText(terminal),
`${DOWNLOAD_PREAMBLE_TEXT}$ `,
"only the human-readable sz preamble text should be shown",
);
assertNoZmodemHeaderBytes(terminal, "byte-at-a-time");
});
test("split sz preamble leaks nothing at any chunk boundary", () => {
const header = buildHexHeaderBytes("ZRQINIT");
// Header.build().to_hex() already includes the trailing XON byte.
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
header,
Buffer.from("$ "),
]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `);
assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`);
}
});
test("fragmented rz ZRINIT preamble never reaches the terminal", () => {
const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]);
const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"root@host:~$ ",
`split ZRINIT preamble leaked for chunkSize=${chunkSize}`,
);
assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`);
}
});
test("bytes withheld as a header fragment are flushed after the TTL", async () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.from("hello *");
for (const byte of stream) sentry.consume(Buffer.from([byte]));
// The trailing "*" may be the start of a hex header, so it is withheld.
assert.equal(joinedText(terminal), "hello ");
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output");
});
test("output that disproves a header fragment is emitted without delay", () => {
const stream = Buffer.from("text **\x18Bxy more");
for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"text **\x18Bxy more",
"a non-hex byte after the pattern stops the deferral",
);
}
});
+1 -37
View File
@@ -1,5 +1,4 @@
import test from 'node:test';
import { normalizeStoredAgentModelSelection, shouldUseStoredAgentModel } from '../../components/AIChatSidePanelHelpers';
import assert from 'node:assert/strict';
import {
@@ -20,22 +19,10 @@ import {
} from './types';
test('Claude presets advertise effort levels separately from the model id', () => {
assert.equal(CLAUDE_MODEL_PRESETS.length, 15);
const withLevels = CLAUDE_MODEL_PRESETS.filter((preset) => preset.thinkingLevels);
assert.ok(withLevels.length > 0);
for (const preset of withLevels) {
for (const preset of CLAUDE_MODEL_PRESETS) {
assert.deepEqual(preset.thinkingLevels, ['low', 'medium', 'high', 'max']);
assert.ok(preset.defaultThinkingLevel);
}
// Current-generation entries keep explicit effort levels; older fallback
// entries and unverified generations omit them rather than inventing.
const byId = new Map(CLAUDE_MODEL_PRESETS.map((preset) => [preset.id, preset]));
for (const id of ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-haiku-4-5']) {
assert.ok(byId.get(id)?.thinkingLevels, `${id} should keep effort levels`);
}
for (const id of ['claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-4-6']) {
assert.equal(byId.get(id)?.thinkingLevels, undefined, `${id} should not invent effort levels`);
}
assert.equal(resolveAgentModelSelection(CLAUDE_MODEL_PRESETS[0]!), 'default/medium');
});
@@ -265,26 +252,3 @@ test('getAgentModelPresets resolves Windows command paths with backslashes', ()
CLAUDE_MODEL_PRESETS,
);
});
test('Claude fallback preserves saved family aliases and effort', () => {
for (const selection of ['sonnet/medium', 'haiku/low']) {
assert.equal(shouldUseStoredAgentModel(selection, CLAUDE_MODEL_PRESETS), true);
assert.equal(normalizeStoredAgentModelSelection(selection, CLAUDE_MODEL_PRESETS), selection);
}
assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'sonnet')?.name, 'Sonnet');
assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'haiku')?.name, 'Haiku');
});
test('explicit Claude fallback IDs preserve unspecified effort and explicit effort choices', () => {
for (const preset of CLAUDE_MODEL_PRESETS.filter((model) => model.id.startsWith('claude-'))) {
assert.equal(normalizeStoredAgentModelSelection(preset.id, CLAUDE_MODEL_PRESETS), preset.id);
assert.equal(resolveAgentModelSelection(preset), preset.id);
for (const level of preset.thinkingLevels ?? []) {
const explicit = `${preset.id}/${level}`;
assert.equal(normalizeStoredAgentModelSelection(explicit, CLAUDE_MODEL_PRESETS), explicit);
}
}
assert.equal(normalizeStoredAgentModelSelection('sonnet/medium', CLAUDE_MODEL_PRESETS), 'sonnet/medium');
assert.equal(normalizeStoredAgentModelSelection('haiku/low', CLAUDE_MODEL_PRESETS), 'haiku/low');
});
+5 -56
View File
@@ -559,76 +559,25 @@ export interface AgentModelPreset {
const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
// Fallback presets used when the live catalog (SDK listModels) is unavailable.
// Mirrors the live model picker catalog observed 2026-10-10 (claude CLI
// 2.1.296); ids are explicit model ids sent to the CLI verbatim and were
// verified against the binary's model-id strings and alias table
// (opus/sonnet/haiku → claude-*-5-5, fable → claude-fable-5-1). Live
// discovery supersedes this list whenever it succeeds. `default` has no
// fixed alias target and keeps the CLI's own neutral label. Explicit IDs
// preserve a saved bare selection so CLI/settings effort remains in control.
export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
{
id: 'default',
name: 'Default (recommended)',
name: 'Opus 4.6',
description: 'Recommended',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'claude-opus-5-5',
name: 'Opus 5.5',
description: 'Flagship',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
},
{
id: 'claude-fable-5-1',
name: 'Fable 5.1',
description: 'Latest Fable',
},
{
id: 'claude-sonnet-5-5',
name: 'Sonnet 5.5',
description: 'Everyday tasks',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
},
{
id: 'claude-haiku-5-5',
name: 'Haiku 5.5',
description: 'Fastest current',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'low',
encodeDefaultThinking: false,
},
{
id: 'claude-haiku-4-5',
name: 'Haiku 4.5',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'low',
encodeDefaultThinking: false,
},
{ id: 'claude-sonnet-5', name: 'Sonnet 5' },
{ id: 'claude-opus-5', name: 'Opus 5' },
{ id: 'claude-fable-5', name: 'Fable 5' },
{ id: 'claude-opus-4-8', name: 'Opus 4.8' },
{ id: 'claude-opus-4-7', name: 'Opus 4.7' },
{ id: 'claude-opus-4-6', name: 'Opus 4.6' },
{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6' },
// Keep persisted pre-expansion selections valid. The installed CLI resolves
// these aliases, so their labels must not claim a particular generation.
{
id: 'sonnet',
name: 'Sonnet',
name: 'Sonnet 4.6',
description: 'Everyday tasks',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'haiku',
name: 'Haiku',
name: 'Haiku 4.5',
description: 'Fastest',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'low',
},
-2
View File
@@ -24,8 +24,6 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
+20 -224
View File
@@ -6,19 +6,7 @@ const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u;
const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]/u;
const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu;
export const PINYIN_CACHE_MAX_ENTRIES = 16_384;
/**
* Hard cap on the total characters the pinyin cache retains (cache key +
* full + initials transliterations per entry, i.e. several copies of the
* source text). A per-entry cap alone is not enough: callers transliterate
* long fields (e.g. up to 20,000-character session titles), so a scan over
* many distinct large fields could retain tens or hundreds of megabytes
* within the entry count. Sources whose transliterations alone exceed this
* budget are not cached at all — they are simply re-transliterated on the
* next lookup instead of pinning memory.
*/
export const PINYIN_CACHE_MAX_CHARS = 1_000_000;
const PINYIN_CACHE = new Map<string, { full: string; initials: string }>();
let pinyinCacheChars = 0;
const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/;
const HOST_PHASE_SCORE_BONUS = {
strict: 1_000_000,
@@ -45,13 +33,6 @@ function compactText(input: string): string {
return normalizeText(input).replace(SEARCH_REMOVE_REGEX, "");
}
function pinyinCacheEntryChars(
key: string,
entry: { full: string; initials: string },
): number {
return key.length + entry.full.length + entry.initials.length;
}
function getPinyinVariants(sourceText: string): { full: string; initials: string } {
const cacheKey = normalizeText(sourceText);
const cached = PINYIN_CACHE.get(cacheKey);
@@ -81,22 +62,11 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
}
const next = { full, initials };
const entryChars = pinyinCacheEntryChars(cacheKey, next);
// Never cache an entry that by itself exceeds the retained-char budget:
// admitting it (and evicting everything older to fit) would still let one
// oversized field pin memory above the budget, and skipping keeps it a
// simple re-transliteration on the next lookup instead of unbounded decay.
if (entryChars > PINYIN_CACHE_MAX_CHARS) return next;
pinyinCacheChars += entryChars;
PINYIN_CACHE.delete(cacheKey);
PINYIN_CACHE.set(cacheKey, next);
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES
|| pinyinCacheChars > PINYIN_CACHE_MAX_CHARS) {
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) {
const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined;
if (oldestKey === undefined) break;
const oldest = PINYIN_CACHE.get(oldestKey);
if (!oldest) break;
pinyinCacheChars -= pinyinCacheEntryChars(oldestKey, oldest);
PINYIN_CACHE.delete(oldestKey);
}
return next;
@@ -104,19 +74,10 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
export function resetPinyinCacheForTests(): void {
PINYIN_CACHE.clear();
pinyinCacheChars = 0;
}
export function getPinyinCacheStatsForTests(): {
size: number;
chars: number;
keys: string[];
} {
return {
size: PINYIN_CACHE.size,
chars: pinyinCacheChars,
keys: [...PINYIN_CACHE.keys()],
};
export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } {
return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] };
}
export function tokenizeSearchQuery(query: string): string[] {
@@ -125,132 +86,35 @@ export function tokenizeSearchQuery(query: string): string[] {
return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean);
}
/**
* Optional matching tweaks. Callers with large unbounded haystacks (e.g.
* full conversation histories) can keep literal/compact matching global
* while restricting the (expensive) pinyin fallback to specific fields via
* `pinyinFields`: each token may still match literally anywhere in the
* haystack, but is only transliteration-matched against the listed fields.
*/
export type SearchMatchOptions = {
allowPinyin?: boolean;
/** Restrict the pinyin fallback to these fields; defaults to every field. */
pinyinFields?: string[];
};
export function matchesSearchQuery(
query: string,
...fields: Array<string | null | undefined>
): boolean {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return true;
/**
* Suggested query bound for callers that rescan many haystacks per keystroke
* (e.g. session-history search). The shared matcher never truncates: callers
* apply this cap at their own input so the bound stays local to them and the
* global match semantics (full-query meaning) are unchanged for everyone else.
*/
export const MAX_SEARCH_QUERY_LENGTH = 256;
/**
* Bound a caller-side search query to `MAX_SEARCH_QUERY_LENGTH` UTF-16 code
* units without splitting a UTF-16 surrogate pair when the cap lands mid-pair
* (e.g. an astral-plane character such as an emoji; drop the lone high
* surrogate). Truncation is a caller concern: see `MAX_SEARCH_QUERY_LENGTH`.
*/
export function truncateQueryForMatch(query: string): string {
if (query.length <= MAX_SEARCH_QUERY_LENGTH) return query;
const truncated = query.slice(0, MAX_SEARCH_QUERY_LENGTH);
// Avoid splitting a UTF-16 surrogate pair when the cap lands mid-pair (e.g.
// an astral-plane character such as an emoji); drop the lone high surrogate.
const tail = truncated.charCodeAt(truncated.length - 1);
return tail >= 0xd800 && tail <= 0xdbff ? truncated.slice(0, -1) : truncated;
}
/**
* Pre-normalized search query. Callers that match the same query against many
* haystacks (e.g. session-history search scans every session per keystroke)
* can prepare the query once per scan instead of paying NFKC normalization,
* tokenization and compaction once per session.
*/
export type PreparedSearchQuery = {
/** Normalized (NFKC, dash-folded, lowercased, trimmed) query. */
normalized: string;
/** Non-empty tokens of `normalized` after separator splitting. */
tokens: string[];
/** Separator-removed form of `normalized` (same as `compactText(normalized)`). */
compact: string;
/** `normalized` looks like a dotted IPv4 address (contiguous-literal mode). */
ipv4Like: boolean;
};
const EMPTY_PREPARED_QUERY: PreparedSearchQuery = {
normalized: "",
tokens: [],
compact: "",
ipv4Like: false,
};
export function prepareSearchQuery(query: string): PreparedSearchQuery {
// No length cap here: truncating globally would silently change match
// semantics for every caller (an over-limit query's tail would be dropped,
// so a field containing only the prefix would report a match). Callers that
// rescan many haystacks bound their own input via `truncateQueryForMatch`.
const normalized = normalizeText(query);
if (!normalized) return EMPTY_PREPARED_QUERY;
return {
normalized,
tokens: normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean),
compact: compactText(normalized),
ipv4Like: IPV4_LIKE_REGEX.test(normalized),
};
}
/**
* Pre-normalized search haystack. Callers that match the same fields against
* many queries (e.g. session-history search fires on every keystroke) can
* prepare the haystack once, cache it, and reuse it per query instead of
* re-running NFKC normalization and compaction over megabytes each time.
*/
export type PreparedSearchFields = {
/** Per-field normalized text, in input order. */
normalizedFields: string[];
/** Normalized fields joined with a single space. */
haystack: string;
/** Separator-removed form of `haystack` (same as `compactText(haystack)`). */
haystackCompact: string;
};
export function prepareSearchFields(
fields: Array<string | null | undefined>,
): PreparedSearchFields {
const normalizedFields = fields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => normalizeText(field));
const haystack = normalizedFields.join(" ");
return { normalizedFields, haystack, haystackCompact: compactText(haystack) };
}
export function matchesPreparedSearchQuery(
query: string | PreparedSearchQuery,
prepared: PreparedSearchFields,
options: SearchMatchOptions = {},
): boolean {
// Raw strings are still accepted (and prepared inline) for one-off callers;
// repeated-match callers pass `prepareSearchQuery(query)` instead.
const { normalized: normalizedQuery, tokens, compact: compactQuery, ipv4Like } =
typeof query === "string" ? prepareSearchQuery(query) : query;
if (!normalizedQuery) return true;
if (prepared.normalizedFields.length === 0) return false;
if (normalizedFields.length === 0) return false;
// For dotted numeric input (IPv4-like), require contiguous literal match.
if (ipv4Like) {
return prepared.normalizedFields.some((field) => field.includes(normalizedQuery));
if (IPV4_LIKE_REGEX.test(normalizedQuery)) {
return normalizedFields.some((field) => field.includes(normalizedQuery));
}
const tokens = tokenizeSearchQuery(normalizedQuery);
if (tokens.length === 0) return false;
const haystack = prepared.haystack;
const sourceText = normalizedFields.join(" ");
const haystack = sourceText;
if (haystack.includes(normalizedQuery)) {
return true;
}
if (compactQuery && prepared.haystackCompact.includes(compactQuery)) {
const haystackCompact = compactText(sourceText);
const compactQuery = compactText(normalizedQuery);
if (compactQuery && haystackCompact.includes(compactQuery)) {
return true;
}
@@ -259,15 +123,9 @@ export function matchesPreparedSearchQuery(
}
const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token));
if (!hasLatinToken || options.allowPinyin === false) return false;
if (!hasLatinToken) return false;
const pinyinSource = options.pinyinFields === undefined
? haystack
: options.pinyinFields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => normalizeText(field))
.join(" ");
const { full, initials } = getPinyinVariants(pinyinSource);
const { full, initials } = getPinyinVariants(sourceText);
if (!full && !initials) return false;
return tokens.every((token) => {
@@ -280,68 +138,6 @@ export function matchesPreparedSearchQuery(
});
}
const DEFAULT_SEARCH_MATCH_OPTIONS: Required<Pick<SearchMatchOptions, "allowPinyin">> = {
allowPinyin: true,
};
export function matchesSearchQuery(
query: string,
...rest: Array<string | null | undefined | SearchMatchOptions>
): boolean {
const args = [...rest];
const options: Pick<SearchMatchOptions, "allowPinyin"> & { pinyinFields?: string[] } =
args.length > 0
&& typeof args[args.length - 1] === "object"
&& args[args.length - 1] !== null
? { ...DEFAULT_SEARCH_MATCH_OPTIONS, ...(args.pop() as SearchMatchOptions) }
: DEFAULT_SEARCH_MATCH_OPTIONS;
const fields = args as Array<string | null | undefined>;
return matchesPreparedSearchQuery(
query,
prepareSearchFields(fields),
{ allowPinyin: options.allowPinyin, pinyinFields: options.pinyinFields },
);
}
/**
* Word-prefix matcher: the query must prefix-match a whole token (word) of one
* of the fields — including that token's pinyin full/initials forms. Strictly
* weaker than matchesSearchQuery's substring behavior; used where substring
* noise is undesirable, e.g. snippet completions while typing an argument
* (issue #3622). Dashes/underscores separate tokens, so "set-email" still
* matches from the word "email".
*/
export function matchesWordPrefixQuery(
query: string,
...fields: Array<string | null | undefined>
): boolean {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return true;
const words = fields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.flatMap((field) => normalizeText(field).split(SEARCH_SPLIT_REGEX))
.filter((word) => word.length > 0);
if (words.length === 0) return false;
// The compact/pinyin fallback must not discard the query's leading
// punctuation: option-style needles like "--ve" or "-d" would otherwise
// match words ("version", "deploy") that never literally start with them
// and displace the real Fig option completions while typing flags.
const compactQuery = /^[a-z0-9]/.test(normalizedQuery)
? compactText(normalizedQuery)
: "";
return words.some((word) => {
if (word.startsWith(normalizedQuery)) return true;
if (!compactQuery) return false;
const { full, initials } = getPinyinVariants(word);
return (
(full !== "" && full.startsWith(compactQuery))
|| (initials !== "" && initials.startsWith(normalizedQuery))
);
});
}
/**
* Host search should avoid mixing label/group tokens with hostname/IP tokens.
* Otherwise queries like "山东 6-1" can accidentally match:
+12 -16
View File
@@ -2,7 +2,6 @@ import type { UploadBridge } from "./uploadService.types";
export class UploadController {
private cancelled = false;
private cancelledFileIds = new Set<string>();
private activeFileTransferIds = new Set<string>();
private activeCompressionIds = new Set<string>();
private currentTransferId = "";
@@ -48,7 +47,16 @@ export class UploadController {
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
await this.cancelFile(transferId);
try {
if (this.bridge?.cancelStagedUploadFile) {
await this.bridge.cancelStagedUploadFile(transferId);
}
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
} catch {
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
@@ -63,22 +71,11 @@ export class UploadController {
}
}
/** Cancel one standalone file without stopping the shared upload queue. */
async cancelFile(transferId: string): Promise<void> {
this.cancelledFileIds.add(transferId);
try {
await this.bridge?.cancelStagedUploadFile?.(transferId);
await this.bridge?.cancelTransfer?.(transferId);
} catch {
// The transfer runtime reconciles backend cancellation failures.
}
}
/**
* Check if upload was cancelled
*/
isCancelled(fileId?: string): boolean {
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
isCancelled(): boolean {
return this.cancelled;
}
/**
@@ -101,7 +98,6 @@ export class UploadController {
*/
reset(): void {
this.cancelled = false;
this.cancelledFileIds.clear();
this.cancelListeners.clear();
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
+2 -8
View File
@@ -769,7 +769,6 @@ async function uploadEntries(
standaloneTransferId: string,
fileTotalBytes: number,
): Promise<{ cancelled?: boolean; error?: string }> => {
if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true };
let localFilePath = getDropEntryLocalPath(entry);
let ownedTempPath: string | undefined;
@@ -785,13 +784,13 @@ async function uploadEntries(
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
} catch (error) {
controller?.removeActiveTransfer(standaloneTransferId);
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
return { cancelled: true };
}
throw error;
}
localFilePath = ownedTempPath;
if (controller?.isCancelled(standaloneTransferId)) {
if (controller?.isCancelled()) {
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
return { cancelled: true };
}
@@ -957,11 +956,6 @@ async function uploadEntries(
);
if (uploadResult.cancelled) {
if (!bundleTaskId && !controller?.isCancelled()) {
settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
results.push({ fileName: entry.relativePath, success: false, cancelled: true });
continue;
}
wasCancelled = true;
settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
+58 -16
View File
@@ -100,7 +100,7 @@
"@vitejs/plugin-react": "^5.1.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.11.12",
"electron": "^42.3.3",
"electron-builder": "^26.15.2",
"esbuild": "0.27.2",
"eslint": "^9.39.1",
@@ -2400,16 +2400,6 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/asar": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz",
@@ -10086,6 +10076,16 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -11366,15 +11366,15 @@
}
},
"node_modules/electron": {
"version": "42.11.12",
"resolved": "https://registry.npmjs.org/electron/-/electron-42.11.12.tgz",
"integrity": "sha512-fHlCHh1uqfvWykQUq8eoSoB5q1gej7z/KKd3m7rqEEhHhihm7qz/EvBIkDat7vQoIq3kGTJcrskLbJ6UFkYGyg==",
"version": "42.3.3",
"resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz",
"integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
"@types/node": "^24.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js",
@@ -12203,6 +12203,27 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fast-check": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
@@ -12318,6 +12339,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -20167,6 +20198,17 @@
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yjs": {
"version": "13.6.31",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz",
+1 -1
View File
@@ -157,7 +157,7 @@
"@vitejs/plugin-react": "^5.1.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.11.12",
"electron": "^42.3.3",
"electron-builder": "^26.15.2",
"esbuild": "0.27.2",
"eslint": "^9.39.1",
+2 -2
View File
@@ -318,7 +318,7 @@ declare global {
}>;
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
@@ -531,7 +531,7 @@ declare global {
): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,