Compare commits

...
Author SHA1 Message Date
bincxz cd15958256 fix: preserve SSH agent fallback through jump hosts 2026-10-15 11:56:29 +08:00
bincxz 0b8b8a5623 fix: preserve password fallback after SSH agent failure 2026-10-15 11:49:40 +08:00
Netcatty-Bot 4af4af274b fix(ssh): accurately report missing remote exit status
Report transport closure without inventing a successful remote exit status, while preserving any actual exit code or signal already received. Reuse the existing exit classification to keep normal exits from being treated as reconnectable disconnects.

Related to #3631. This changes the diagnostic message only; the frequent-disconnection report remains open.

Validation: 458 focused checks, lint, production build, real Electron/local SSH disconnect workflow, current-head Codex review, and repository CI passed.
2026-10-15 11:43:32 +08:00
GOUKI9999andClaude Sonnet 5.5 ccfc88cd50 fix(ai): check only the tail of the output for the idle prompt (#3661)
hasExpectedPromptSuffix ran for every PTY chunk and normalised the whole
retained output (up to 256K chars of ANSI/CR stripping) just to test whether
it ends with the prompt. During an output flood with CRLF line endings that
kept the terminal worker busy for seconds at a time, delaying cancel
requests, timers and keepalive handling (#3443). Look at a bounded tail
window instead; the result is unchanged for any prompt that fits in it.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:07:57 +08:00
Netcatty-Bot 602a24a991 fix(zmodem): keep rz upload progress moving on SSH backpressure (#3665)
Report delivered-byte rz progress while parked on SSH backpressure, and race each progress poll against drain so upload resumes immediately when the channel drains.

Fixes #3663
2026-10-14 10:42:49 +08:00
Netcatty-Bot 5df2181392 fix(terminal): self-heal cursor line highlight on external decoration disposal (#3657)
Recreate cursor-line markers/decorations when xterm disposes them externally, and treat empty decoration sets as intact so fully colored rows do not churn markers every frame.

Fixes #3656
2026-10-13 17:35:01 +08:00
Netcatty-Bot 451205770e fix(zmodem): withhold split ZRQINIT/ZRINIT preamble instead of leaking it to the terminal (#3658)
Fixes #3651. Codex P2 on >1s mid-prefix stall skipped by judgment as edge-case thrash relative to ordinary chunk-boundary splits.
2026-10-13 17:30:43 +08:00
Netcatty-Bot 231982d673 fix(window): fit minimum size to small displays and restore terminal focus after resize (#3648)
Clamps the window layout minimum to the current display work area so small screens (e.g. 1366×768 at 125%) can fit and shrink. Restores terminal keyboard focus after OS resize/maximize, without stealing from active editable fields.

Fixes #3647

Merged by judgment: four Codex rounds already addressed the real bugs and multi-monitor sync; the latest P2 (protect every non-editable focusable control on resize) is edge-case thrash.
2026-10-13 17:29:35 +08:00
陈大猫 8eb52853a0 fix(ssh): skip auto-reconnect after an administrative exit-signal (#3659)
Classify SSH exit-signal as signaled so auto-reconnect does not fight admin idle kicks. Bare transport closed and error still reconnect.

Related to #3617.
2026-10-13 17:29:16 +08:00
11c90b45ec feat(terminal): disable auto-close-on-exit by default (#3619)
* feat(terminal): disable auto-close-on-exit by default

Remote graceful channel closes (VPN half-close, server-side idle kick) can be reported as a clean exit with code 0, so with auto-close enabled the tab gets destroyed exactly when the user needs it kept - bypassing the SSH auto-reconnect loop, which intentionally skips exited events.

Keep tabs on exit by default; users who want close-on-exit can enable it in Settings > Terminal > Behavior. Attach-mode close behavior is unchanged.

* fix(terminal): keep session tabs when the auto-close setting is unavailable

The resolver's own default and the TerminalLayer call-site fallback still
treated a missing terminal setting as auto-close enabled. Remote graceful
channel closes (a jump host reaping an idle session) are reported as a
clean exit, so before the settings store had loaded, those closes silently
deleted the session tab and its scrollback. This completes the opt-in
default introduced above: only an explicit autoCloseOnExit true closes tabs.

* fix(terminal): migrate the materialized auto-close-on-exit default once

Builds before the default flip shipped autoCloseOnExit: true as the
default and materialized it into netcatty_term_settings_v1 on any
settings write, so most upgraders carry a value they never chose and
the new default-off never reaches them: bastion and idle-harvesting
servers reporting clean exits keep silently deleting their tabs.

Reset that materialized true exactly once behind a marker key before
the settings blob is first ingested, then persist the marker so every
later stored value is treated as the user's own choice. The cost is
documented: a user who deliberately enabled auto-close before this
release must re-enable it once.

* fix(terminal): keep synced legacy auto-close from coming back

A device that already wrote the one-shot migration marker was accepting
a pre-migration cloud or backup payload with autoCloseOnExit true.
Unversioned true is rewritten to false. Payloads that record the
migration still sync an explicit opt-in, including across a three-way
merge that would otherwise glue the local marker onto a legacy true.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: xu <tbqyomnnnbltzxy2t@aliyun.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:11:15 +08:00
ac644a5e51 feat(terminal): enable SSH auto-reconnect by default (#3617)
* feat(terminal): enable SSH auto-reconnect by default

Unexpected transport drops (VPN / Wi-Fi blips) are recoverable events, not
shell exits: the tab stays and the 5s in-place reconnect loop keeps the same
session id, so AI sessions and long-running work continue once the link is
back. With the previous default (off), a dropped link left the tab parked on
'Press Enter to reconnect', which reads as 'the session closed'.

Clean shell exits still close the tab; explicit opt-out in Settings is
preserved. mosh/et sessions self-heal and are unaffected by this gate.

* fix(terminal): skip auto-reconnect after idle timeouts

SSH auto-reconnect stays on by default for transport drops. A timeout
exit (TMOUT, server idle logout, or connect timeout) leaves the tab
disconnected instead of logging back in every 5 seconds.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: yuyang2230 <yuyang2230@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:11:00 +08:00
0f890701d4 feat(ai): raise default AI session idle timeout from 30m to 8h (#3618)
* feat(ai): raise default AI session idle timeout from 30m to 24h

Sessions opened via host_open are auto-closed after 30 minutes without
terminal/file activity. In production use over flaky links this silently
destroyed in-flight agent work: the AI reopens a host, the link drops, the
session sits disconnected (counts as idle), and the reaper closes it
mid-workflow with no error surfaced anywhere. 24h is the normalize clamp
maximum; users can still choose a shorter window in Settings → AI, and the
idle manager still skips sessions with active executions.

* fix(ai): preserve 24-hour session default during renderer sync

* style(ai): keep session idle manager comment ASCII-only

Replace the unicode arrow in the Settings pointer comment with ASCII per the repo AGENTS.md convention (Maintain ASCII-only unless required by existing file content).

* fix(ai): default AI session idle timeout to 8 hours

480 minutes still covers a workday of flaky-link agent work without
sitting on the 24h clamp. Stored values are left untouched.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: yuyang2230 <yuyang2230@users.noreply.github.com>
Co-authored-by: 陈奇 <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:10:53 +08:00
予明andxu 21e6a91f36 fix(ai): shed oversized tool output before evicting visible chat history (#3643)
* fix(ai): shed oversized tool output before evicting visible chat history

Under localStorage quota pressure the AI sessions serializer degraded
replay ciphertext fine-grained but never shed tool result payloads, so
AI-driven terminal chats (whose persisted bulk is terminal output)
forced the ladder to evict whole oldest sessions and finally collapse
persistence to the newest chat alone. After a restart the history list
looked empty.

Extend the per-message degradation pass to cap oversized tool result
content at a bounded head+tail with a truncation marker, mirroring the
existing ciphertext-shedding tradeoff: every visible conversation now
survives storage pressure at the cost of full tool output on affected
messages.

* fix(ai): keep tool-result truncation strictly smaller than the original

Codex review P2: for tool results only slightly over the cap, the
truncation marker (with JSON-escaped newlines) could make the degraded
copy serialize at the same size or larger than the original, breaking
the ladder's assumption that the degraded representation is the
smallest and sending quota retries farther off.

Drop the marker newlines and shrink the retained head+tail until the
whole copy fits the 8 KiB cap, so a truncated result is always
strictly shorter than its source.

---------

Co-authored-by: xu <tbqyomnnnbltzxy2t@aliyun.com>
2026-10-13 16:44:38 +08:00
陈大猫 d3c4273886 fix(sftp): cancel independent uploads without stopping siblings (#3638) 2026-10-13 16:44:33 +08:00
陈大猫 747fdeb0f3 fix(shortcuts): save modifier edits when the chord is released (#3641) 2026-10-13 16:44:27 +08:00
minmie 0bb75055c4 fix(ci): unbreak Claude Code smoke helper freeze step (#3586)
The `Freeze trusted helper` step in the `sandbox_smoke` job used a plain
multi-line YAML scalar:

    run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
      chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"

YAML folds the continuation line into the same scalar, so the step ran:

    cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs" chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"

`cp` then sees four operands and treats the last one as the destination
directory, which does not exist:

    cp: target '/home/runner/work/_temp/ai-automation.cjs': No such file or directory

The job fails in 7s at step 3, and the remaining 12 steps are skipped.

Reuse the existing `&freeze_ai_helper` anchor, which is what the other eight
`Freeze trusted helper` steps in this workflow already do. It also picks up
`set -euo pipefail` and `test -f scripts/ai-automation.cjs`, so a missing
helper now fails with a clear message instead of a confusing `cp` error.

Observed on a fork mirror running commit b2ed83ae: runs 36847532363 and
36991962473 both failed at this step.
2026-10-13 16:44:22 +08:00
f82355cf1b fix(ssh): inject ET PATH ssh wrapper without a host OpenSSH binary (#3650)
Unit tests stub execFileSync and only found ssh via /usr/bin/ssh, so the
PATH wrapper was skipped on images without that path. Prefer an absolute
findExecutable result, then command -v, then the well-known locations.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 15:56:04 +08:00
43249cd7e0 test(ssh): do not dial the SSH port for stats latency (#3649)
Stats latency already pings the authenticated session. Lock that in with
a unit test that fails if getServerStats dials the session host, which
is what produced kex_exchange_identification every ~30s (#3630).

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 15:55:57 +08:00
GOUKI9999 483410b132 fix(ai): expand Claude fallback while preserving saved selections (#3625)
Retain family aliases and bare explicit model IDs without introducing an unsolicited effort suffix.
2026-10-12 12:26:07 +08:00
Netcatty-Bot 201ebcb21a feat(ai): search session history titles and bounded content (#3601)
Keep history search bounds local and preserve complete-field shared pinyin matching.

Fixes #3598
2026-10-12 11:52:21 +08:00
Netcatty-Bot 6ac6c3dbfa fix(snippets): preserve all-hosts scope in snippet import/export (#3636)
Preserve portable all-hosts and group scopes. Export v3 and accept v1/v2/v3; older importers reject v3.

Fixes #3635
2026-10-12 11:50:31 +08:00
Netcatty-Bot 11f2b7dbd8 fix(terminal): surface snippet completions while typing an argument (#3623)
Match argument snippets conservatively and remove stale snippet rows on input edits before the debounced refresh.

Related to #3622; the email-entry/editor concern remains unverified and the issue stays open.
2026-10-12 11:41:39 +08:00
Netcatty-Bot af574078e7 fix(crash-logs): cap per-day crash log file size and trim oversized logs (#3627)
Bound daily crash log growth and trim oversized regular files after validating the opened inode.

Fixes #3626.
2026-10-12 11:41:02 +08:00
陈大猫 c89e4c7246 fix(notes): preserve pending drafts after storage write failures (#3629)
Preserve pending local notes and conflicting external content across failed writes and successful retries.

Retain explicit clear/import replacement ownership until the current generation persists.
2026-10-12 11:40:17 +08:00
陈大猫 282a557f67 fix: upgrade Electron to preserve sessions on second launch (#3628)
Upgrade Electron to 42.11.12 with its extraction dependency changes.

Related to #3391.
2026-10-12 11:39:37 +08:00
github-actions[bot]andgithub-actions[bot] 7cb3e2e2ad chore: update star history charts [skip ci] (#3637)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-12 00:12:10 +00:00
88 changed files with 6283 additions and 309 deletions

No files matched your search

+1 -2
View File
@@ -1202,8 +1202,7 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
run: *freeze_ai_helper
- name: Install Claude Code CLI
run: |
+4
View File
@@ -528,6 +528,10 @@ 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,6 +519,10 @@ 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,6 +475,10 @@ 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,6 +528,10 @@ 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,6 +518,10 @@ 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,6 +466,115 @@ 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[] = [];
+79 -29
View File
@@ -195,12 +195,40 @@ 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. 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.
* 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.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
@@ -218,7 +246,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
};
}
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 => {
@@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripEncryptedReasoningFromMessage(message);
const stripped = stripReasoningCiphertextFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -310,6 +354,11 @@ 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[],
@@ -321,13 +370,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 ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -339,7 +388,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
ciphertextMessages,
degradableMessages,
};
});
@@ -364,11 +413,12 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// 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.
// 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.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
@@ -378,27 +428,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
strippedMessagesByIndex.get(messageIndex) ?? message
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
@@ -8,10 +8,20 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
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.
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
{ kind: "markDisconnected" },
);
});
@@ -57,11 +67,19 @@ 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", () => {
@@ -133,6 +151,17 @@ 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({
@@ -150,3 +179,25 @@ 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,7 +4,10 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
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;
// 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".
@@ -16,6 +19,13 @@ 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})` : ""}]`;
}
@@ -34,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
autoCloseOnExit = true,
// 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,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -0,0 +1,790 @@
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
@@ -0,0 +1,382 @@
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,9 +9,14 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -140,3 +145,72 @@ 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;
}
});
+29 -1
View File
@@ -1,5 +1,9 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -63,6 +67,30 @@ 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 } from "../../../lib/uploadService";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,3 +37,64 @@ 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,11 +5,13 @@ 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);
@@ -19,6 +21,7 @@ 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);
@@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -40,6 +44,10 @@ 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()];
@@ -50,5 +58,6 @@ 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) => {
registerExternalUploadController(taskId, controller);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller);
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -58,6 +58,52 @@ 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" },
+10 -1
View File
@@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
// "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;
}
export function shouldContinueAutoReconnectAfterFailure({
@@ -1,5 +1,8 @@
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,
@@ -18,6 +21,7 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
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 = 30;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
@@ -0,0 +1,24 @@
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,6 +151,7 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -355,6 +356,9 @@ 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);
});
+52 -10
View File
@@ -80,6 +80,7 @@ import {
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
@@ -306,6 +307,12 @@ export const useVaultState = () => {
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const notesStorageRef = useRef<VaultNote[]>([]);
// An optimistic React echo is not a persistence acknowledgement. Keep this
// owner until that exact generation has successfully reached storage.
const notesPendingRef = useRef<VaultNote[] | null>(null);
// Failed clear/import keeps full-snapshot ownership across later local edits.
const notesPendingReplaceRef = useRef(false);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
@@ -834,17 +841,37 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const updateNotes = useCallback((data: Partial<VaultNote>[], options?: { replace?: boolean }) => {
const stored = normalizeVaultNotes(localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES) ?? []);
const requested = normalizeVaultNotes(data);
// Explicit clear/restore keeps its existing full-snapshot semantics.
const replace = options?.replace === true || notesPendingReplaceRef.current;
const pending = replace ? requested : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: requested,
theirs: stored,
});
notesStorageRef.current = stored;
notesPendingRef.current = pending;
notesPendingReplaceRef.current = replace;
notesRef.current = pending;
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
data: pending,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (notesPendingRef.current === pending) {
notesRef.current = cleaned;
if (persisted) {
notesStorageRef.current = cleaned;
notesPendingRef.current = null;
notesPendingReplaceRef.current = false;
}
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
@@ -1163,7 +1190,7 @@ export const useVaultState = () => {
updateProxyProfiles([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNotes([], { replace: true });
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
@@ -1534,8 +1561,10 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes) {
if (savedNotes && notesPendingRef.current === null) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
notesStorageRef.current = cleanedNotes;
notesRef.current = cleanedNotes;
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
@@ -1780,8 +1809,21 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_NOTES) {
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
// Like snippets, ignore queued events for a snapshot no longer on disk.
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_NOTES)) return;
// Only a successful current write can release an outstanding replacement.
if (notesPendingReplaceRef.current) return;
const next = normalizeVaultNotes(safeParse<VaultNote[]>(event.newValue) ?? []);
const pending = notesPendingRef.current;
const visible = pending === null ? next : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: pending,
theirs: next,
});
notesStorageRef.current = next;
if (pending !== null) notesPendingRef.current = visible;
notesRef.current = visible;
setNotes(visible);
return;
}
@@ -1952,7 +1994,7 @@ export const useVaultState = () => {
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
if (payload.notes) updateNotes(payload.notes, { replace: true });
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
+44
View File
@@ -1667,6 +1667,50 @@ 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,6 +25,7 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -62,6 +63,7 @@ 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,
@@ -505,6 +507,9 @@ 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);
@@ -795,6 +800,23 @@ 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));
}
@@ -0,0 +1,126 @@
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();
}
});
+64 -5
View File
@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, 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
@@ -31,13 +34,28 @@ 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]);
}, [sessions, searchQuery]);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -50,6 +68,41 @@ 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 ? (
@@ -58,6 +111,12 @@ 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 && (
+3 -1
View File
@@ -669,7 +669,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
evt,
terminalSettings?.autoCloseOnExit ?? true,
// 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,
);
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+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
);
assert.match(
terminalSource,
@@ -0,0 +1,336 @@
import assert from "node:assert/strict";
import test from "node:test";
import { register } from "node:module";
import React, { act } from "react";
import type { VaultNote } from "../../domain/models.ts";
// JSDOM has no stylesheet loader. All editor/application JavaScript stays real.
register(`data:text/javascript,${encodeURIComponent(`
export async function load(url, context, nextLoad) {
if (url.endsWith("/components/notes/noteMath.scss")) {
return { format: "module", source: "export {};", shortCircuit: true };
}
return nextLoad(url, context);
}
`)}`, import.meta.url);
// Same browser shims as InlineMarkdownEditor.unrenderableMarkdown.test.tsx.
import {JSDOM} from "jsdom";
const setupDom = () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
function LoadedImageStub() {
const image = window.document.createElement("img");
Object.defineProperty(image, "src", {
get: () => image.getAttribute("src") ?? "",
set: (src: string) => {
image.setAttribute("src", src);
queueMicrotask(() => image.dispatchEvent(new window.Event("load")));
},
});
return image;
}
Object.assign(window.Range.prototype, {
getClientRects: () => [],
getBoundingClientRect: () => new window.DOMRect(),
});
// CodeMirror needs these browser APIs when the full note contains code blocks.
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
});
for (const [key, value] of Object.entries({
window,
Window: window.Window,
Image: LoadedImageStub,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
HTMLImageElement: window.HTMLImageElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
Element: window.Element,
SVGElement: window.SVGElement,
Node: window.Node,
DocumentFragment: window.DocumentFragment,
Range: window.Range,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
CustomEvent: window.CustomEvent,
DOMRect: window.DOMRect,
Event: window.Event,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
ResizeObserver: ResizeObserverStub,
IS_REACT_ACT_ENVIRONMENT: true,
})) {
installGlobal(key, value);
}
return {
window,
cleanup() {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
dom.window.close();
},
};
};
for (const mode of ["source", "edit"] as const) {
test(`mounted notes ${mode}: failed save retains both versions through retry and switching`, { timeout: 30_000 }, async (t) => {
const { window, cleanup } = setupDom();
// The app initializes unrelated vault collections under Web Locks. Serialize
// those calls in the fixture too (Node 22 does not always expose Web Locks).
const tails = new Map<string, Promise<unknown>>();
Object.defineProperty(window.navigator, "locks", { value: {
request(name: string, callback: () => unknown) {
const run = (tails.get(name) ?? Promise.resolve()).then(callback, callback);
tails.set(name, run.then(() => undefined, () => undefined));
return run;
},
} });
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: window.localStorage });
const keys = await import("../../infrastructure/config/storageKeys.ts");
for (const [name, key] of Object.entries(keys)) {
if (name.startsWith("STORAGE_KEY_") && typeof key === "string") window.localStorage.setItem(key, "[]");
}
const original: VaultNote = { id: "synthetic-note", title: "Synthetic note", content: "Persisted baseline", createdAt: 1, updatedAt: 1, order: 0 };
const second: VaultNote = { ...original, id: "second-note", title: "Second note", content: "Second body", order: 1 };
window.localStorage.setItem(keys.STORAGE_KEY_NOTES, JSON.stringify([original, second]));
window.localStorage.setItem(keys.STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, mode);
const { useVaultState } = await import("../../application/state/useVaultState.ts");
const { getNotesSnapshot, subscribeNotes } = await import("../../application/state/notesStore.ts");
const { setNotify } = await import("../../application/notification.ts");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("../ui/tooltip.tsx");
const { NotesManager } = await import("./NotesManager.tsx");
const { createRoot } = await import("react-dom/client");
const rootNode = window.document.getElementById("root")!;
const root = createRoot(rootNode);
let vault!: ReturnType<typeof useVaultState>;
let openNote!: React.Dispatch<React.SetStateAction<string>>;
const saveResults: boolean[] = [];
const notices: string[] = [];
let quota = false;
const realSetItem = window.Storage.prototype.setItem;
window.Storage.prototype.setItem = function (key: string, value: string) {
if (quota && key === keys.STORAGE_KEY_NOTES) throw new DOMException("Synthetic quota exceeded", "QuotaExceededError");
return realSetItem.call(this, key, value);
};
setNotify({ error: message => notices.push(message), info: () => {}, warning: () => {}, success: () => {} });
function App() {
const state = useVaultState();
vault = state;
const [noteId, setNoteId] = React.useState(original.id);
openNote = setNoteId;
const updateNotes = state.updateNotes;
const save = React.useCallback((notes: VaultNote[]) => {
const result = updateNotes(notes);
saveResults.push(result);
return result;
}, [updateNotes]);
return <I18nProvider locale="en"><TooltipProvider><NotesManager notes={state.notes} noteGroups={state.noteGroups}
hosts={[]} onUpdateNotes={save} onUpdateNoteGroups={state.updateNoteGroups} displayMode="full" openNoteId={noteId} />
</TooltipProvider></I18nProvider>;
}
const editor = () => mode === "source" ? rootNode.querySelector<HTMLTextAreaElement>("textarea") : rootNode.querySelector<HTMLElement>('[contenteditable="true"]');
const visible = () => (mode === "source" ? (editor() as HTMLTextAreaElement)?.value : editor()?.textContent)?.trim();
const waitFor = async (check: () => boolean, label: string) => {
const deadline = Date.now() + 6000;
while (!check()) {
assert.ok(Date.now() < deadline, `${label}; saves=${JSON.stringify(saveResults)}, visible=${visible()}, notes=${JSON.stringify(vault.notes)}`);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
}
};
const persisted = (): VaultNote[] => JSON.parse(window.localStorage.getItem(keys.STORAGE_KEY_NOTES)!);
const primary = () => vault.notes.find(note => note.id === original.id)!;
const edit = async (content: string) => {
await act(async () => {
if (mode === "source") {
const textarea = editor() as HTMLTextAreaElement;
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, content);
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
} else {
const lexical = await import("lexical");
const instance = lexical.getNearestEditorFromDOMNode(editor()!);
assert.ok(instance);
instance.update(() => { lexical.$getRoot().clear().append(lexical.$createParagraphNode().append(lexical.$createTextNode(content))); }, { discrete: true });
}
});
await waitFor(() => visible() === content, "local edit is visible");
};
const incoming = async (notes: VaultNote[]) => {
const value = JSON.stringify(notes);
// Independent writer bypasses only our synthetic quota failure.
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, value);
await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", { key: keys.STORAGE_KEY_NOTES, newValue: value, storageArea: window.localStorage })); });
};
try {
await act(async () => root.render(<App />));
await waitFor(() => vault.isInitialized && visible() === original.content, "initial editor");
await edit("Successfully saved");
await waitFor(() => saveResults.length === 1, "normal successful debounce");
assert.equal(saveResults[0], true);
assert.equal(persisted()[0].content.trim(), "Successfully saved");
await incoming([{ ...original, content: "Legitimate external update", updatedAt: 5 }, second]);
await waitFor(() => visible() === "Legitimate external update", "external update after successful save");
quota = true;
const local = "Unsaved local draft";
await edit(local);
assert.equal(saveResults.length, 1, "dirty control precedes autosave echo");
await incoming([{ ...original, content: "Remote before echo", updatedAt: 10 }, second]);
assert.equal(primary().content, "Remote before echo");
assert.equal(visible(), local, "existing editor dirty guard works before echo");
await waitFor(() => saveResults.length === 2 && primary().content.trim() === local, "failed save optimistic echo");
assert.equal(saveResults[1], false);
assert.equal(notices.length, 1);
assert.equal(persisted()[0].content, "Remote before echo");
assert.ok(primary().updatedAt > 11, "incoming timestamps deliberately run behind local time");
const remote = [{ ...original, content: "Remote after echo", updatedAt: 11 }, second];
await incoming(remote);
assert.equal(visible(), local, "failed optimistic echo must not acknowledge durability");
assert.equal(primary().content.trim(), local);
assert.equal(getNotesSnapshot().notes.find(note => note.id === original.id)!.content.trim(), local);
const copy = vault.notes.find(note => note.id !== original.id && note.content === "Remote after echo");
assert.ok(copy, "external conflicting content must remain accessible as a note");
const copyId = copy.id;
await incoming(remote);
assert.equal(vault.notes.length, 3, "repeated storage delivery creates no duplicate copy");
await edit("Unsaved local draft revision two");
await waitFor(() => saveResults.length === 3, "second failed debounce");
assert.equal(saveResults[2], false);
assert.equal(notices.length, 1, "repeated failure respects notification throttling");
await act(async () => openNote(second.id));
await waitFor(() => visible() === second.content, "switch to second note");
await act(async () => openNote(original.id));
await waitFor(() => visible() === "Unsaved local draft revision two", "switch back retains failed draft");
// Switching notes intentionally defers the content swap by two frames.
await act(async () => { await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve))); });
assert.equal(vault.notes.find(note => note.id === copyId)?.content, "Remote after echo");
quota = false;
await edit("Durable recovered draft");
await waitFor(() => saveResults.length === 4, "successful retry");
assert.equal(saveResults[3], true);
assert.equal(persisted().find(note => note.id === original.id)?.content.trim(), "Durable recovered draft");
assert.equal(persisted().find(note => note.id === copyId)?.content, "Remote after echo");
const afterSuccess = persisted().map(note => note.id === original.id ? { ...note, content: "External after recovery", updatedAt: 2 } : note);
await incoming(afterSuccess);
await waitFor(() => visible() === "External after recovery", "successful same-generation save releases pending protection");
assert.equal(vault.notes.length, 3);
await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", {
key: keys.STORAGE_KEY_NOTES, newValue: JSON.stringify(remote), storageArea: window.localStorage,
})); });
assert.equal(visible(), "External after recovery", "queued obsolete storage events must not roll back a successful save");
// Publishing a successful generation can synchronously trigger another
// write. The older acknowledgement must never clear the newer failure.
let nestedResult: boolean | undefined;
let triggered = false;
const unsubscribe = subscribeNotes(() => {
if (triggered) return;
triggered = true;
quota = true;
nestedResult = vault.updateNotes(getNotesSnapshot().notes.map(note => note.id === original.id ? { ...note, content: "Newer failed generation" } : note));
});
try {
await act(async () => {
assert.equal(vault.updateNotes(vault.notes.map(note => note.id === original.id ? { ...note, content: "Older successful generation" } : note)), true);
});
} finally { unsubscribe(); }
assert.equal(nestedResult, false);
await incoming(persisted().map(note => note.id === original.id ? { ...note, content: "Concurrent with newer failure" } : note));
assert.equal(primary().content, "Newer failed generation");
assert.ok(vault.notes.some(note => note.content === "Concurrent with newer failure"));
// Full-snapshot replacement (clear/restore) remains explicit and does not
// accidentally resurrect a disk-only note through the save-time rebase.
quota = false;
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...persisted(), { ...second, id: "disk-only" }]));
await act(async () => { assert.equal(vault.updateNotes([original], { replace: true }), true); });
assert.deepEqual(persisted().map(note => note.id), [original.id]);
for (const operation of ["clear", "import"] as const) {
await t.test(`failed ${operation} retains replacement ownership through events and retry`, async () => {
quota = false;
await act(async () => { assert.equal(vault.updateNotes([original, second], { replace: true }), true); });
const replacement = operation === "clear" ? [] : [{ ...original, content: "Imported snapshot" }];
quota = true;
await act(async () => {
if (operation === "clear") vault.clearVaultData();
else await vault.importDataFromString(JSON.stringify({ notes: replacement }));
});
const contents = (notes: VaultNote[]) => notes.map(({ id, content }) => [id, content]);
assert.deepEqual(contents(vault.notes), contents(replacement));
assert.deepEqual(contents(persisted()), contents([original, second]), "replacement has not reached disk");
const peer = [
{ ...original, content: "Changed in another window", updatedAt: 30 }, second,
{ ...second, id: "peer-added", content: "Added in another window" },
];
await incoming(peer);
assert.deepEqual(contents(vault.notes), contents(replacement), "failed replacement must not adopt peer edits or additions");
assert.deepEqual(contents(getNotesSnapshot().notes), contents(replacement));
const edited = operation === "clear"
? [{ ...original, id: "created-after-clear", content: "New local note" }]
: replacement.map(note => ({ ...note, content: "Edited after import" }));
await act(async () => { assert.equal(vault.updateNotes(edited), false); });
await incoming([...peer, { ...second, id: "peer-added-again" }]);
assert.deepEqual(contents(vault.notes), contents(edited), "an ordinary failed edit must inherit replacement ownership");
// Also cover a peer write whose storage event has not arrived yet.
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...peer, { ...second, id: "unobserved-peer" }]));
quota = false;
await act(async () => { assert.equal(vault.updateNotes(vault.notes), true); });
assert.deepEqual(contents(persisted()), contents(edited), "successful retry persists only the replacement catalog");
const afterReplacement = [{ ...second, id: "legitimate-later-peer" }];
await incoming(afterReplacement);
assert.deepEqual(contents(vault.notes), contents(afterReplacement), "successful current owner releases replacement protection");
});
}
console.log(`NOTES_FIX_${mode.toUpperCase()}: success, dirty-before-echo, failed-echo conflict, duplicate delivery, repeated failure, switch, retry, legal external update passed`);
} finally {
await act(async () => root.unmount());
window.Storage.prototype.setItem = realSetItem;
setNotify({ error: () => {}, info: () => {}, warning: () => {}, success: () => {} });
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else delete (globalThis as Record<string, unknown>).localStorage;
cleanup();
}
});
}
@@ -0,0 +1,86 @@
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,6 +64,7 @@ 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).
@@ -77,8 +78,6 @@ 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("⌘");
@@ -95,6 +94,11 @@ 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;
@@ -106,6 +110,17 @@ 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();
};
@@ -115,9 +130,11 @@ 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,6 +1,7 @@
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
@@ -10,7 +11,7 @@ import { JSDOM } from "jsdom";
* the hovered row and self-oscillating (violent mousemove jitter).
*/
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => {
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async (t) => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
@@ -194,6 +195,38 @@ 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,16 +406,32 @@ export async function getCompletions(
}
}
// Snippets: only at the command position (typing the command name).
// 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.
// 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 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
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);
}
}
}
@@ -8,7 +8,7 @@
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import { matchesSearchQuery, matchesWordPrefixQuery } 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 } = {},
options: { hostId?: string; hostGroup?: string; prefixOnly?: boolean } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
@@ -44,10 +44,16 @@ 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.
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
// 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);
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. Other sources keep their provider-specific
* matching semantics (snippet search, plugin replacement, etc.).
* 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.
*/
export function reconcileAutocompletePopupState(
prev: AutocompleteState,
@@ -158,6 +158,7 @@ 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,8 +14,28 @@ test("getCompletions includes snippet suggestions at the command position", asyn
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions does not surface snippets past the command position", async () => {
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
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] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
@@ -0,0 +1,258 @@
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());
});
@@ -0,0 +1,104 @@
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 }> = [];
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
const term = {
cols,
@@ -477,6 +477,84 @@ 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,6 +37,8 @@ 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;
@@ -46,10 +48,16 @@ export class CursorLineHighlighter implements IDisposable {
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
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();
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
@@ -111,6 +119,7 @@ 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
@@ -160,13 +169,18 @@ 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 = [];
}
}),
);
@@ -244,6 +258,7 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
@@ -251,6 +266,19 @@ 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,3 +111,31 @@ 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("provider-specific non-history rows remain until their debounced refresh", () => {
test("snippet rows are removed synchronously while their next query is pending", () => {
const snippet: CompletionSuggestion = {
text: "deploy production",
displayText: "deploy production",
@@ -186,7 +186,8 @@ test("provider-specific non-history rows remain until their debounced refresh",
};
const next = reconcileAutocompletePopupState(popupState([snippet]), "dp");
assert.deepEqual(next.suggestions, [snippet]);
assert.deepEqual(next.suggestions, []);
assert.equal(next.popupVisible, false);
assert.equal(next.selectedIndex, -1);
});
@@ -1,6 +1,8 @@
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) => ({
@@ -348,3 +350,32 @@ 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,6 +23,7 @@ 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';
@@ -747,6 +748,23 @@ 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="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="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="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.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>
<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>
<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="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="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="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.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>
<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>
<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

+9 -2
View File
@@ -536,7 +536,10 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
middleClickPaste: true,
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
linkModifier: 'none',
autoCloseOnExit: true,
// 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,
// Issue #3087: keep terminal history visible after an established session disconnects.
disconnectedNoticeMode: 'terminal',
keywordHighlightEnabled: true,
@@ -552,7 +555,11 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
sshAutoReconnectEnabled: false,
// 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,
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,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
PINYIN_CACHE_MAX_CHARS,
PINYIN_CACHE_MAX_ENTRIES,
getPinyinCacheStatsForTests,
getHostSearchMatch,
@@ -11,6 +12,21 @@ 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"),
@@ -125,6 +141,18 @@ 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", {
@@ -227,3 +255,22 @@ 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
@@ -0,0 +1,414 @@
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,3 +280,76 @@ 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);
});
+46 -11
View File
@@ -4,7 +4,11 @@ import { normalizeVaultOrder } from "./vaultOrder";
import { normalizeGroupTargetPaths } from "./hostGroupPathMutations";
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
export const SNIPPET_EXPORT_VERSION = 2 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_LEGACY = 1 as const;
export type SnippetImportConflictAction = "skip" | "overwrite";
@@ -15,6 +19,7 @@ export type SnippetExportItem = {
tags?: string[];
package?: string;
targetGroups?: string[];
targetsAllHosts?: boolean;
shortkey?: string;
noAutoRun?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
@@ -27,7 +32,10 @@ export type SnippetExportItem = {
export type SnippetExportPayload = {
kind: typeof SNIPPET_EXPORT_KIND;
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
version:
| typeof SNIPPET_EXPORT_VERSION
| typeof SNIPPET_EXPORT_VERSION_PREVIOUS
| typeof SNIPPET_EXPORT_VERSION_LEGACY;
exportedAt: string;
snippetPackages: string[];
snippets: SnippetExportItem[];
@@ -88,12 +96,20 @@ 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.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}),
...(snippet.targetsAllHosts
? { targetsAllHosts: true }
: snippet.targetGroups !== undefined
? { targetGroups: [...snippet.targetGroups] }
: {}),
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}),
@@ -134,6 +150,8 @@ 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,
@@ -141,11 +159,14 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
? uniqueStrings(value.tags)
: [],
package: typeof value.package === "string" ? value.package.trim() : "",
targetGroups: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
targetGroups: targetsAllHosts
? undefined
: Array.isArray(value.targetGroups)
? normalizeGroupTargetPaths(value.targetGroups.filter(
(entry): entry is string => typeof entry === "string",
))
: undefined,
targetsAllHosts: targetsAllHosts || undefined,
shortkey: typeof value.shortkey === "string" && value.shortkey.trim()
? value.shortkey.trim()
: undefined,
@@ -174,7 +195,11 @@ 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_LEGACY) {
if (
version !== SNIPPET_EXPORT_VERSION &&
version !== SNIPPET_EXPORT_VERSION_PREVIOUS &&
version !== SNIPPET_EXPORT_VERSION_LEGACY
) {
throw new Error("Unsupported snippet import file.");
}
if (!Array.isArray(parsed.snippets)) {
@@ -187,7 +212,12 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
return {
kind: SNIPPET_EXPORT_KIND,
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
version:
version === SNIPPET_EXPORT_VERSION
? SNIPPET_EXPORT_VERSION
: version === SNIPPET_EXPORT_VERSION_PREVIOUS
? SNIPPET_EXPORT_VERSION_PREVIOUS
: SNIPPET_EXPORT_VERSION_LEGACY,
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
snippetPackages: collectSnippetPackagePaths(
snippets,
@@ -244,8 +274,13 @@ 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.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}),
...(item.targetsAllHosts
? { targetsAllHosts: true }
: 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,6 +313,12 @@ 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;
@@ -390,6 +396,27 @@ 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,3 +631,44 @@ 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,6 +21,7 @@
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import {
payloadOptsIntoAutoClose,
sanitizeHostsForSync,
type CloudSyncPayloadEntityKey,
type SyncPayload,
@@ -403,6 +404,24 @@ 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 enables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
});
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
});
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
});
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { VaultNote } from "./models.ts";
import { normalizeVaultNotes } from "./notes.ts";
import { rebasePendingVaultNotes } from "./vaultNotesReconciliation.ts";
const note = (id: string, content: string, extra: Partial<VaultNote> = {}) =>
normalizeVaultNotes([{ id, title: id, content, createdAt: 1, updatedAt: 1, order: 0, ...extra }])[0];
test("pending notes retain both conflicting versions without choosing by timestamp", () => {
const base = [note("a", "base")];
const ours = [note("a", "local", { updatedAt: 1000 })];
const theirs = [note("a", "remote", { updatedAt: 2, tags: ["external"], group: "folder" })];
const merged = rebasePendingVaultNotes({ base, ours, theirs });
assert.deepEqual(merged[0], ours[0]);
assert.equal(merged.length, 2);
assert.notEqual(merged[1].id, "a");
assert.equal(merged[1].title, "a (conflict copy)");
assert.equal(merged[1].content, "remote");
assert.deepEqual(merged[1].tags, ["external"]);
assert.equal(merged[1].group, "folder");
assert.equal(merged[1].updatedAt, 2);
assert.deepEqual(rebasePendingVaultNotes({ base: theirs, ours: merged, theirs }), merged,
"repeated delivery keeps the same copy ID");
});
test("pending note edits preserve unrelated external updates, additions and deletions", () => {
const base = [note("a", "base"), note("b", "base"), note("c", "base")];
const ours = [note("a", "local"), base[1], base[2], note("d", "local addition")];
const theirs = [base[0], note("b", "remote"), note("e", "remote addition")];
const result = rebasePendingVaultNotes({ base, ours, theirs });
assert.deepEqual(result.map(({ id, content }) => [id, content]), [
["a", "local"], ["b", "remote"], ["d", "local addition"], ["e", "remote addition"],
]);
});
test("conflicting delete/edit keeps the surviving content; uncontested deletes stay deleted", () => {
const base = [note("a", "base")];
const edited = [note("a", "changed")];
assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: edited }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: [] }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: base }), []);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: base, theirs: [] }), []);
});
test("equal local and remote notes do not produce a conflict copy", () => {
const base = [note("a", "base")];
const edited = [note("a", "same")];
assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: edited }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base: [], ours: edited, theirs: edited }), edited);
});
+40
View File
@@ -0,0 +1,40 @@
import type { VaultNote } from "./models.ts";
import { normalizeVaultNotes } from "./notes.ts";
import { getNextVaultOrder } from "./vaultOrder.ts";
/** Reconcile a pending local write with the last observed storage snapshot.
* Keep conflicting remote content as an ordinary, exportable note; timestamps
* are compared only for equality, never used to decide which writer wins.
*/
export function rebasePendingVaultNotes(input: {
base: VaultNote[];
ours: VaultNote[];
theirs: VaultNote[];
}): VaultNote[] {
const base = new Map(input.base.map(note => [note.id, note]));
const ours = new Map(input.ours.map(note => [note.id, note]));
const theirs = new Map(input.theirs.map(note => [note.id, note]));
const same = (a: VaultNote | undefined, b: VaultNote | undefined) =>
JSON.stringify(a) === JSON.stringify(b);
const notes: VaultNote[] = [];
const copies: VaultNote[] = [];
let copyOrder = getNextVaultOrder([...input.ours, ...input.theirs]);
for (const id of new Set([...ours.keys(), ...theirs.keys()])) {
const ancestor = base.get(id);
const local = ours.get(id);
const remote = theirs.get(id);
if (same(local, ancestor)) {
if (remote) notes.push(remote);
} else if (same(remote, ancestor) || same(local, remote)) {
if (local) notes.push(local);
} else {
if (local) notes.push(local);
if (remote) {
// A concurrent delete must not discard the surviving edited content.
if (!local) notes.push(remote);
else copies.push({ ...remote, id: crypto.randomUUID(), title: `${remote.title} (conflict copy)`, order: copyOrder++ });
}
}
}
return normalizeVaultNotes([...notes, ...copies]);
}
+16 -2
View File
@@ -58,11 +58,25 @@ 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, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
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);
}
function escapePosixSingleQuoted(text) {
@@ -0,0 +1,52 @@
"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`);
});
+182 -4
View File
@@ -3,7 +3,9 @@
*
* 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.
* 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.
*/
const fs = require("node:fs");
@@ -24,6 +26,15 @@ 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",
@@ -74,6 +85,11 @@ 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"));
@@ -100,8 +116,8 @@ function buildEntry(source, err, extra) {
return {
timestamp: new Date().toISOString(),
source,
message: error.message || String(err),
stack: error.stack || undefined,
message: boundedText(error.message || String(err), MAX_MESSAGE_CHARS),
stack: boundedText(error.stack, MAX_STACK_CHARS) || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
@@ -120,6 +136,70 @@ 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).
@@ -130,8 +210,9 @@ function captureError(source, err, extra) {
if (!dir) return;
const entry = buildEntry(source, err, extra);
const line = serializeEntry(entry);
const filePath = path.join(dir, todayFileName());
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
appendEntryLine(filePath, line);
} catch {
// Never throw from the crash logger itself.
}
@@ -174,6 +255,89 @@ 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
// ---------------------------------------------------------------------------
@@ -331,6 +495,7 @@ function init(deps) {
// ignore
}
pruneOldLogs();
trimOversizedLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
@@ -359,9 +524,22 @@ 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
@@ -0,0 +1,162 @@
"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,6 +1,10 @@
"use strict";
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
// 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 MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
@@ -0,0 +1,193 @@
"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);
});
});
+6 -1
View File
@@ -813,7 +813,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}
resolve();
});
conn.once('error', (err) => {
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;
}
if (settled) return;
settled = true;
clearAuthReadyTimer();
@@ -2415,7 +2415,90 @@ 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, "closed");
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGUSR2");
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);
});
}
@@ -0,0 +1,57 @@
/**
* 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,
};
@@ -0,0 +1,73 @@
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, // TCP connection establishment latency to the SSH endpoint (ms)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -746,6 +746,46 @@ 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",
+69 -27
View File
@@ -27,6 +27,10 @@ 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;
@@ -544,6 +548,15 @@ 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 */ }
},
@@ -665,7 +678,8 @@ 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.
// timeout) have code=null and signal set — those are not user exits
// and are reported as "signaled", not the reconnectable "closed".
// 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
@@ -676,8 +690,17 @@ 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 = typeof signal === "string" && signal ? signal : null;
streamExitSignal = normalizeRemoteExitSignal(signal);
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;
@@ -686,6 +709,7 @@ function createStartSessionApi(ctx) {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExitSignal,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
@@ -717,27 +741,20 @@ function createStartSessionApi(ctx) {
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// 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).
// 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).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
const exitPayload = {
const exitPayload = buildRemoteShellExitPayload({
sessionId,
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;
}
generation: liveSession?._terminalSessionGeneration,
exitCode: streamExitCode,
exitSignal: streamExitSignal,
shellExited: streamExited,
idleTimedOut,
});
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
@@ -2464,6 +2481,16 @@ 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
@@ -2638,13 +2665,28 @@ function createStartSessionApi(ctx) {
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else {
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
} 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,
exitCode: 0,
reason: "closed",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
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({
sessionId,
generation: session?._terminalSessionGeneration,
exitCode: session?._remoteExitCode,
shellExited,
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
}));
}
// Use this connection's captured token so a late close from an
// old transport can't stop a newer same-sessionId stream (#916).
+14 -7
View File
@@ -832,13 +832,19 @@ 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.
let realSsh = process.platform === "win32"
? (findExecutable("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.
}
if (!realSsh || !path.isAbsolute(realSsh)) {
try {
const resolved = execFileSync(
const lookup = execFileSync(
process.platform === "win32" ? "where" : "sh",
process.platform === "win32" ? ["ssh"] : ["-c", "command -v ssh"],
{
@@ -847,8 +853,9 @@ main();
windowsHide: true,
env: process.env,
},
).trim().split(/\r?\n/)[0];
if (resolved) realSsh = resolved;
);
const resolved = String(lookup ?? "").trim().split(/\r?\n/).find(Boolean) || "";
if (resolved && path.isAbsolute(resolved)) realSsh = resolved;
} catch {
// Fall through to common absolute locations.
}
@@ -26,6 +26,13 @@ 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" }),
@@ -53,7 +60,7 @@ function makeApi(t, overrides = {}) {
trackSessionIdlePrompt: () => {},
createPtyOutputBuffer: overrides.createPtyOutputBuffer
|| (() => ({ bufferData() {}, flush() {}, flushPaced() {} })),
findExecutable: () => "ssh",
findExecutable: (name) => (name === "ssh" ? fakeSsh : name),
bundledEtClient,
});
return { api, base, sessions };
+3
View File
@@ -76,6 +76,9 @@ 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;
+136 -4
View File
@@ -6,6 +6,104 @@ 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;
@@ -113,11 +211,24 @@ 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, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
@@ -149,8 +260,8 @@ function createMainWindowApi(ctx) {
const win = new BrowserWindow({
...windowBounds,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
backgroundColor,
icon: appIcon,
show: false,
@@ -190,6 +301,20 @@ 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 {
@@ -197,6 +322,11 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
try {
detachDisplayMinimumSync?.();
} catch {
// ignore
}
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
@@ -595,6 +725,8 @@ function createMainWindowApi(ctx) {
}
module.exports = {
attachDisplayMinimumSync,
createMainWindowApi,
resolveWindowMinimumSize,
setTerminalKeyboardFocusForWindow,
};
@@ -0,0 +1,194 @@
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)));
});
+272 -30
View File
@@ -107,6 +107,11 @@ 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]
@@ -131,6 +136,11 @@ 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;
@@ -152,6 +162,16 @@ 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
@@ -240,6 +260,14 @@ 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;
@@ -253,12 +281,7 @@ function createZmodemSentry(opts) {
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
isHexHeader = false;
break;
}
@@ -274,6 +297,10 @@ 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;
}
}
@@ -287,6 +314,119 @@ 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;
@@ -453,8 +593,19 @@ function createZmodemSentry(opts) {
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data – pass raw bytes to the caller for charset-aware decoding.
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
// 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);
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
@@ -766,6 +917,8 @@ 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;
@@ -981,6 +1134,76 @@ 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
@@ -1183,19 +1406,42 @@ 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
@@ -1223,7 +1469,6 @@ 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);
@@ -1243,21 +1488,16 @@ async function handleUpload(zsession, opts) {
const now = Date.now();
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
emitFileProgress();
lastProgressEmitAt = now;
}
// 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();
// 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);
}
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
@@ -1542,6 +1782,8 @@ module.exports = {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
};
+406
View File
@@ -11,6 +11,7 @@ const {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
} = require("./zmodemHelper.cjs");
@@ -422,6 +423,282 @@ 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");
@@ -1489,3 +1766,132 @@ 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",
);
}
});
+37 -1
View File
@@ -1,4 +1,5 @@
import test from 'node:test';
import { normalizeStoredAgentModelSelection, shouldUseStoredAgentModel } from '../../components/AIChatSidePanelHelpers';
import assert from 'node:assert/strict';
import {
@@ -19,10 +20,22 @@ import {
} from './types';
test('Claude presets advertise effort levels separately from the model id', () => {
for (const preset of CLAUDE_MODEL_PRESETS) {
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) {
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');
});
@@ -252,3 +265,26 @@ 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');
});
+56 -5
View File
@@ -559,25 +559,76 @@ 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: 'Opus 4.6',
name: 'Default (recommended)',
description: 'Recommended',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'sonnet',
name: 'Sonnet 4.6',
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',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'haiku',
name: 'Haiku 4.5',
description: 'Fastest',
name: 'Haiku',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'low',
},
+2
View File
@@ -24,6 +24,8 @@ 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';
+224 -20
View File
@@ -6,7 +6,19 @@ 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,
@@ -33,6 +45,13 @@ 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);
@@ -62,11 +81,22 @@ 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) {
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES
|| pinyinCacheChars > PINYIN_CACHE_MAX_CHARS) {
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;
@@ -74,10 +104,19 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
export function resetPinyinCacheForTests(): void {
PINYIN_CACHE.clear();
pinyinCacheChars = 0;
}
export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } {
return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] };
export function getPinyinCacheStatsForTests(): {
size: number;
chars: number;
keys: string[];
} {
return {
size: PINYIN_CACHE.size,
chars: pinyinCacheChars,
keys: [...PINYIN_CACHE.keys()],
};
}
export function tokenizeSearchQuery(query: string): string[] {
@@ -86,35 +125,132 @@ export function tokenizeSearchQuery(query: string): string[] {
return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean);
}
export function matchesSearchQuery(
query: string,
...fields: Array<string | null | undefined>
): boolean {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return true;
/**
* 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[];
};
/**
* 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));
if (normalizedFields.length === 0) return false;
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;
// For dotted numeric input (IPv4-like), require contiguous literal match.
if (IPV4_LIKE_REGEX.test(normalizedQuery)) {
return normalizedFields.some((field) => field.includes(normalizedQuery));
if (ipv4Like) {
return prepared.normalizedFields.some((field) => field.includes(normalizedQuery));
}
const tokens = tokenizeSearchQuery(normalizedQuery);
if (tokens.length === 0) return false;
const sourceText = normalizedFields.join(" ");
const haystack = sourceText;
const haystack = prepared.haystack;
if (haystack.includes(normalizedQuery)) {
return true;
}
const haystackCompact = compactText(sourceText);
const compactQuery = compactText(normalizedQuery);
if (compactQuery && haystackCompact.includes(compactQuery)) {
if (compactQuery && prepared.haystackCompact.includes(compactQuery)) {
return true;
}
@@ -123,9 +259,15 @@ export function matchesSearchQuery(
}
const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token));
if (!hasLatinToken) return false;
if (!hasLatinToken || options.allowPinyin === false) return false;
const { full, initials } = getPinyinVariants(sourceText);
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);
if (!full && !initials) return false;
return tokens.every((token) => {
@@ -138,6 +280,68 @@ export function matchesSearchQuery(
});
}
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:
+16 -12
View File
@@ -2,6 +2,7 @@ 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 = "";
@@ -47,16 +48,7 @@ export class UploadController {
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
if (this.bridge?.cancelStagedUploadFile) {
await this.bridge.cancelStagedUploadFile(transferId);
}
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
} catch {
// Ignore cancel errors
}
await this.cancelFile(transferId);
}
// Also cancel current one if not in the set
@@ -71,11 +63,22 @@ 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(): boolean {
return this.cancelled;
isCancelled(fileId?: string): boolean {
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
}
/**
@@ -98,6 +101,7 @@ export class UploadController {
*/
reset(): void {
this.cancelled = false;
this.cancelledFileIds.clear();
this.cancelListeners.clear();
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
+8 -2
View File
@@ -769,6 +769,7 @@ 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;
@@ -784,13 +785,13 @@ async function uploadEntries(
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
} catch (error) {
controller?.removeActiveTransfer(standaloneTransferId);
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
return { cancelled: true };
}
throw error;
}
localFilePath = ownedTempPath;
if (controller?.isCancelled()) {
if (controller?.isCancelled(standaloneTransferId)) {
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
return { cancelled: true };
}
@@ -956,6 +957,11 @@ 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));
+16 -58
View File
@@ -100,7 +100,7 @@
"@vitejs/plugin-react": "^5.1.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.3.3",
"electron": "^42.11.12",
"electron-builder": "^26.15.2",
"esbuild": "0.27.2",
"eslint": "^9.39.1",
@@ -2400,6 +2400,16 @@
"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",
@@ -10076,16 +10086,6 @@
"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.3.3",
"resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz",
"integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==",
"version": "42.11.12",
"resolved": "https://registry.npmjs.org/electron/-/electron-42.11.12.tgz",
"integrity": "sha512-fHlCHh1uqfvWykQUq8eoSoB5q1gej7z/KKd3m7rqEEhHhihm7qz/EvBIkDat7vQoIq3kGTJcrskLbJ6UFkYGyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0",
"extract-zip": "^2.0.1"
"@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js",
@@ -12203,27 +12203,6 @@
"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",
@@ -12339,16 +12318,6 @@
"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",
@@ -20198,17 +20167,6 @@
"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.3.3",
"electron": "^42.11.12",
"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; // TCP connection establishment latency to the SSH endpoint
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
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; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,