Compare commits

..
Author SHA1 Message Date
bincxz 0d942d4563 fix(ai): retain old source chats when undoing a turn 2026-10-15 12:56:47 +08:00
71373d1ad1 feat(ai-chat): add non-destructive undo last turn with safe-boundary branching (#3602)
* fix(#3599): automated AI fix

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* refactor(ai-chat): limit undo to conversation and draft restoration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:50:14 +08:00
Netcatty-Bot b4e056dd4a fix(sftp): support writable GVFS destinations without losing file protection
Handle unsupported copyFile operations on writable GVFS/FUSE destinations using the existing exclusive bounded copy path. New files accept the mount's creation permissions; replacing an existing file still requires equivalent protection, and failures retain recoverable data.

Only explicitly optional permission/timestamp updates may tolerate permission refusal. Content writes, close errors, required protection, and rollback preservation remain strict. Current-head review and required checks were verified before merging.

Related to #3611. Keep the report open pending delivery and actual environment confirmation.
2026-10-15 12:19:58 +08:00
d5c7d3fbf4 fix(ai): make host_open reuse-first and keep reconnecting sessions in get_environment (#3614)
* fix(#3613): automated AI fix

* fix(ai): preserve owners when reusing active host sessions

* fix(ai): match opening connection identity before host tab reuse

* fix(ai): preserve serial settings when matching reusable tabs

* fix: capture restored plugin connection before host reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:17:34 +08:00
陈大猫 1ab07ffee4 fix(ssh): preserve password fallback after unavailable agent
Allow ssh2 to continue to another authentication method when a system SSH agent is unavailable, for both target and jump-host connections. Keep the authentication deadline and fatal-error handling intact.

Validated with real SSH servers and Unix agent sockets through normal terminal startup, including forwarding, successful password fallback, real authentication failures, and timeouts. 118 focused checks and lint passed. Current-head review and required CI were checked before merging.

Related to #3640. Keep the report open for the reporter's environment and delivery validation.
2026-10-15 12:13:43 +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
114 changed files with 7684 additions and 374 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: |
+6
View File
@@ -53,6 +53,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -1432,6 +1433,10 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1474,6 +1479,7 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
+6
View File
@@ -522,12 +522,18 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'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.',
+6
View File
@@ -513,12 +513,18 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
'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.',
+6
View File
@@ -469,12 +469,18 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'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 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
+6
View File
@@ -522,12 +522,18 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'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 分钟)。你可以重新发送消息来重试。',
+6
View File
@@ -512,12 +512,18 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'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({
+33
View File
@@ -86,6 +86,11 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -697,6 +702,32 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1275,6 +1306,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1335,6 +1367,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
@@ -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,148 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
@@ -0,0 +1,85 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
type HostOpenSessionReuseInput = {
sessions: TerminalSession[];
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
resolveEffectiveHost: (host: Host) => Host;
};
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
delete options.sudo;
delete options.fileProtocol;
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol: host.protocol || 'ssh',
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
pluginConnection: host.pluginConnection,
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
}, [hosts, keys, identities, resolveEffectiveHost]);
useLayoutEffect(() => {
const liveIds = new Set(sessions.map((session) => session.id));
for (const id of openingKeys.current.keys()) {
if (!liveIds.has(id)) openingKeys.current.delete(id);
}
for (const session of sessions) {
if (openingKeys.current.has(session.id) || session.restoreState) continue;
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, Promise.resolve(undefined));
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
return useCallback(async (hostId: string) => {
const host = hosts.find((candidate) => candidate.id === hostId);
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => (
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
)));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
return undefined;
}
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
}
@@ -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);
});
+2
View File
@@ -53,6 +53,7 @@ export interface UseVaultAgentBridgeInput {
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -271,6 +272,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+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));
}
+23 -1
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus } from 'lucide-react';
import { History, Plus, Undo2 } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -55,6 +55,9 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -132,6 +135,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
@@ -232,6 +237,23 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -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 && (
@@ -120,7 +120,9 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
assert.match(panel, /sending=\{isSending\}/);
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+93 -4
View File
@@ -65,6 +65,7 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -269,6 +270,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -319,6 +321,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
@@ -821,6 +828,78 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -1177,8 +1256,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
@@ -1314,6 +1393,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1438,6 +1521,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1452,6 +1536,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1465,6 +1550,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1546,7 +1633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1834,7 +1921,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending}
sending={isSending || isUndoingLastTurn}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1864,6 +1951,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
+4
View File
@@ -37,6 +37,10 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
+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,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);
});
}
@@ -584,6 +584,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
@@ -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

+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
import {
buildUndoLastTurnBranch,
hasUnresolvedToolCalls,
resolveUndoLastTurnBoundary,
} from './aiUndoLastTurn.ts';
const user = (content: string): ChatMessage => ({
id: `u_${content}`,
role: 'user',
content,
timestamp: 1,
});
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id: `a_${content}`,
role: 'assistant',
content,
timestamp: 2,
...extra,
});
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
id: 'chat-1',
title: 'Chat',
agentId: 'catty',
scope: { type: 'terminal', targetId: 't1' },
messages,
createdAt: 1,
updatedAt: 2,
...extra,
});
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
});
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
assert.equal(resolveUndoLastTurnBoundary([]), null);
});
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
});
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
// The call sits in the kept prefix while its result would be cut away.
const split = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
}),
user('again?'),
assistant('', {
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
];
assert.equal(resolveUndoLastTurnBoundary(split), null);
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
// Result already inside the kept prefix — the boundary is safe.
const resolved = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
user('again?'),
assistant('done'),
];
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
});
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
const source = session([
user('first'),
assistant('answer'),
user('bad prompt'),
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.equal(result.session.id, 'chat-2');
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
assert.equal(result.restored.text, 'bad prompt');
// Original session untouched (non-destructive).
assert.equal(source.messages.length, 4);
});
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
const source = session(
[user('compacted'), assistant('x'), user('last'), assistant('y')],
{
externalSessionId: 'prov-1',
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
},
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
assert.ok(result);
assert.equal(result.session.externalSessionId, undefined);
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
});
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
const source = session([
user('with file'),
assistant('ok'),
{
id: 'u_last',
role: 'user',
content: 'look at this',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
],
},
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
assert.ok(result);
assert.deepEqual(result.restored.attachments, [
{
id: 'undo_99_0',
filename: 'notes.txt',
mediaType: 'text/plain',
base64Data: 'QUJD',
dataUrl: 'data:text/plain;base64,QUJD',
lineCount: 3,
},
]);
});
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
const source = session([
user('first'),
assistant('answer'),
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
// Original session untouched (non-destructive).
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
});
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
});
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
assert.equal(
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
null,
);
});
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
// Undoing the first turn leaves the branch with no user message at all, so
// the inherited title was derived from the removed prompt and would stay
// stale forever (auto-titling only runs for untitled/New Chat sessions).
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 0);
assert.equal(result?.restored.text, 'first prompt');
assert.equal(result?.session.title, 'New Chat');
});
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
// A note-only send persists empty user text while the mentioned note rides
// on the attachment (its title seeded the conversation title), so a
// blank-content retained user turn must still count as "a user message is
// kept" and must not reset the title to the untitled placeholder.
const noteOnlyUser: ChatMessage = {
id: 'u_note-only',
role: 'user',
content: '',
timestamp: 3,
attachments: [{
base64Data: '',
mediaType: 'text/markdown',
filename: 'plan.md',
vaultNoteId: 'note-1',
vaultNoteTitle: 'Deploy plan',
previewText: '# Deploy plan',
}],
};
const source = session(
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'Deploy plan' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.title, 'Deploy plan');
});
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
const source = session(
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'renamed by user' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 2);
assert.equal(result?.restored.text, 'second prompt');
// The retained prefix still contains a user message, so the source title
// keeps describing the branch's conversation.
assert.equal(result?.session.title, 'renamed by user');
});
test('undo allows a failed last reply while retaining portable replay metadata', () => {
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const source = session([
user('first'),
assistant('first answer', { providerContinuation: continuation }),
user('retry this request'),
assistant('request failed', { executionStatus: 'failed' }),
], { externalSessionId: 'original-provider-thread' });
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
assert.ok(result);
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
assert.equal(result.session.externalSessionId, undefined);
assert.equal(result.restored.text, 'retry this request');
assert.equal(source.messages.length, 4);
});
test('safe boundary does not pair a reused call ID with an earlier result', () => {
const prefix = [
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
];
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
assert.equal(resolveUndoLastTurnBoundary([
...prefix,
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
user('latest'),
]), 4);
});
+154
View File
@@ -0,0 +1,154 @@
import type {
AISession,
ChatMessage,
ChatMessageAttachment,
UploadedFile,
} from '../infrastructure/ai/types';
/**
* Non-destructive "undo last turn" for AI chat sessions.
*
* Undo branches the conversation at the boundary *before* the latest user
* message instead of truncating the original session. The original session
* stays in history untouched; the branch (without the last turn) becomes the
* conversation the user continues, and the undone user message is restored
* into the composer for editing and resending.
*/
export interface UndoLastTurnRestoredDraft {
text: string;
attachments: UploadedFile[];
selectedUserSkillSlugs: string[];
}
export interface UndoLastTurnResult {
/** Branched copy of the source session without the last user turn. */
session: AISession;
/** Undone user message content for the composer. */
restored: UndoLastTurnRestoredDraft;
}
/**
* True when any assistant tool call in `messages` has no matching tool result
* inside the same list. Cutting between a tool call and its result would
* desync agent tool bookkeeping, so such boundaries are rejected.
*/
export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean {
// Providers may reuse tool-call IDs across a session. Pair each result with
// the nearest preceding unresolved call carrying the same ID (same strategy
// as `repairToolMessageIntegrity`), otherwise a result for an earlier
// occurrence would satisfy a later call that reused the ID.
const pendingCalls = new Map<string, number>();
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1);
}
for (const result of message.toolResults ?? []) {
const pending = pendingCalls.get(result.toolCallId) ?? 0;
if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1);
}
}
for (const pending of pendingCalls.values()) {
if (pending > 0) return true;
}
return false;
}
/**
* Index of the cut point for undoing the last turn (the last user message).
* Returns null when undo is not offered:
* - there is no user message at all,
* - the boundary would fall inside the compacted prefix (its summary would no
* longer match the kept messages),
* - cutting there would split an assistant tool call from its result.
*/
export function resolveUndoLastTurnBoundary(
messages: readonly ChatMessage[],
compactedMessageCount = 0,
): number | null {
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === 'user') {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) return null;
if (lastUserIndex < compactedMessageCount) return null;
const keptPrefix = messages.slice(0, lastUserIndex);
if (hasUnresolvedToolCalls(keptPrefix)) return null;
return lastUserIndex;
}
/** Map a persisted message attachment back to a composer upload (by value). */
export function buildRestoredAttachments(
message: ChatMessage,
now: number,
): UploadedFile[] {
const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? [];
return attachments.map((attachment, index) => ({
id: `undo_${now}_${index}`,
filename: attachment.filename ?? '',
mediaType: attachment.mediaType,
base64Data: attachment.base64Data,
dataUrl: attachment.base64Data
? `data:${attachment.mediaType};base64,${attachment.base64Data}`
: '',
...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}),
...(attachment.terminalSelection ? { terminalSelection: true } : {}),
...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}),
...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}),
...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}),
...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}),
}));
}
/**
* Build the branched "undo last turn" copy of a session.
*
* The branch keeps the compacted prefix intact (its summary still describes
* the kept messages) and drops the external agent session id: external agents
* resume provider-side sessions, so the branch must start a fresh one.
*/
export function buildUndoLastTurnBranch(
source: AISession,
options: { newId: string; now: number },
): UndoLastTurnResult | null {
const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0;
const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount);
if (boundary == null) return null;
const removedUserMessage = source.messages[boundary];
const retainedPrefix = source.messages.slice(0, boundary);
// When undo removes the first turn, the branch has no user message left and
// still carries the source title, which was auto-titled from the removed
// prompt. Reset it to the untitled placeholder so `autoTitleSession` can
// retitle the branch from the prompt the user sends next; otherwise the
// branch is permanently labeled with text that is no longer its prompt.
// A retained user turn can legitimately carry empty text: a note-only send
// leaves `content` blank while the mentioned note rides on the attachment
// (and its title) — that turn still anchored the conversation, so it must
// keep the note-derived title and suppress the reset.
const hasRetainedUserMessage = retainedPrefix.some(
message => message.role === 'user',
);
const branchedSession: AISession = {
...source,
id: options.newId,
messages: retainedPrefix,
...(hasRetainedUserMessage ? {} : { title: 'New Chat' }),
externalSessionId: undefined,
createdAt: options.now,
updatedAt: options.now,
};
return {
session: branchedSession,
restored: {
text: removedUserMessage.content,
attachments: buildRestoredAttachments(removedUserMessage, options.now),
// The undone turn was selected with these skill pills: they shaped the
// model context, so resending the turn must not silently omit them.
selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])],
},
};
}
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { TerminalSession } from './models';
import { findReusableHostSession } from './hostOpenSession';
function tab(id: string, status: TerminalSession['status'], extra: Partial<TerminalSession> = {}): TerminalSession {
return { id, hostId: 'host-1', hostLabel: 'Host', hostname: 'example.invalid', username: 'user', status, ...extra };
}
test('host reuse prefers a connected tab over a connecting tab for the same host', () => {
assert.deepEqual(findReusableHostSession([
tab('other-host', 'connected', { hostId: 'host-2' }),
tab('retrying', 'connecting'),
tab('live', 'connected'),
], 'host-1'), { sessionId: 'live', connected: true });
});
test('host reuse keeps the same tab during the reconnect delay and connection attempt', () => {
assert.deepEqual(findReusableHostSession([tab('retrying', 'connecting')], 'host-1'), {
sessionId: 'retrying', connected: false,
});
});
test('host reuse excludes restored placeholders and inactive disconnected tabs', () => {
assert.equal(findReusableHostSession([
tab('manual-disconnect', 'disconnected'),
tab('restored', 'disconnected', { restoreState: 'restored-disconnected' }),
tab('restoring', 'connecting', { restoreState: 'restored-disconnected' }),
], 'host-1'), undefined);
assert.equal(findReusableHostSession([tab('live', 'connected')], ''), undefined);
});
+14
View File
@@ -0,0 +1,14 @@
import type { TerminalSession } from './models';
export function findReusableHostSession(sessions: TerminalSession[], hostId: string) {
if (!hostId) return undefined;
// Auto-reconnect publishes connecting even during the retry delay. A plain
// disconnected/restored tab needs user action and cannot satisfy host_open.
const matches = sessions.filter((session) => (
session.hostId === hostId
&& session.restoreState !== 'restored-disconnected'
&& (session.status === 'connected' || session.status === 'connecting')
));
const picked = matches.find((session) => session.status === 'connected') ?? matches[0];
return picked ? { sessionId: picked.id, connected: picked.status === 'connected' } : undefined;
}
+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", () => {
+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);
});
+45 -15
View File
@@ -8,24 +8,27 @@ const fs = require("node:fs");
// but no pathname cleanup may remove a concurrent writer's replacement.
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
// verify an open destination handle before later metadata stamping.
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle, options = {}) {
assertNotCancelled();
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
if (!options.copyOnly) {
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
}
let input;
let output;
let failure;
let publishedIdentity;
let timestampsPreserved = true;
try {
input = preparedHandle ?? await fs.promises.open(source, "r");
const stat = await input.stat();
@@ -49,14 +52,31 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
assertNotCancelled();
// Restore metadata after writes (which may clear special permission bits),
// through the owned handle rather than a potentially replaced pathname.
await output.chmod(stat.mode & 0o7777);
await output.utimes(stat.atime, stat.mtime);
const mode = stat.mode & 0o7777;
if (((await output.stat()).mode & 0o7777) !== mode) {
try {
await output.chmod(mode);
} catch (error) {
// New files follow the mount's access policy (GVFS can synthesize mode
// bits). Replacements must still preserve the validated protection.
if (!isOptionalMetadataError(error) || options.preserveMode !== false) throw error;
}
if (options.preserveMode !== false && ((await output.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
try {
await output.utimes(stat.atime, stat.mtime);
} catch (error) {
if (!isOptionalMetadataError(error) || options.requireTimestamps !== false) throw error;
timestampsPreserved = false;
}
const ownedStat = await output.stat();
const targetStat = await fs.promises.lstat(target);
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
throw new Error("Local download target changed during replacement");
}
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size, timestampsPreserved };
} catch (error) {
failure = error;
} finally {
@@ -73,4 +93,14 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
return publishedIdentity;
}
module.exports = { publishLocalFileExclusive };
// Only optional chmod/utimes may ignore permission refusal. Never use this
// predicate for creation, data writes, close, or required permission changes.
function isOptionalMetadataError(error) {
return isMetadataUnsupportedError(error) || ["EPERM", "EACCES"].includes(error?.code);
}
function isMetadataUnsupportedError(error) {
return ["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(error?.code);
}
module.exports = { publishLocalFileExclusive, isMetadataUnsupportedError };
+27 -6
View File
@@ -1615,6 +1615,17 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
// Reuse adds visibility only: never grant ownership or change the original
// owner's idle lifecycle merely because another chat referenced the tab.
if (result?.reused === true) {
if (generation?.ownershipGeneration?.revoked) {
// A late renderer merge must not recreate a deleted chat's scope.
scopedMetadata.delete(chatSessionId);
return;
}
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
return;
}
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
@@ -2232,10 +2243,16 @@ async function handleGetContext(params) {
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
const hasTransport = hasCommandablePty || hasSshExec || hasSerialPort;
// Look up metadata scoped to this chat session.
const scopeMeta = getSessionMeta(sessionId, chatSessionId);
// Disconnected/reconnecting tabs keep their session entry (SSH
// auto-reconnect reuses the sessionId), so keep them visible with
// connected:false instead of hiding them — otherwise the agent cannot
// see or wait for a tab it opened and reopens it as a duplicate.
// Without scoped metadata the session is not in this agent's scope.
if (!hasTransport && !scopeMeta) continue;
const meta = scopeMeta || {};
hosts.push({
sessionId,
hostname: meta.hostname || session.hostname || "",
@@ -2245,7 +2262,7 @@ async function handleGetContext(params) {
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
connected: meta.connected !== undefined ? meta.connected : hasTransport,
hostId: meta.hostId || "",
hostChain: meta.hostChain || [],
activePortForwards: meta.activePortForwards || [],
@@ -2257,7 +2274,10 @@ async function handleGetContext(params) {
for (const sessionId of resolvedScopedIds) {
if (addedHostIds.has(sessionId)) continue;
const meta = getSessionMeta(sessionId, chatSessionId);
if (!meta || meta.connected === false) continue;
if (!meta) continue;
// Keep disconnected/reconnecting sessions listed with connected:false so
// the agent can wait on the existing sessionId instead of reopening the
// host as a duplicate tab (SSH auto-reconnect preserves the sessionId).
hosts.push(buildHostFromMetadata(sessionId, meta));
addedHostIds.add(sessionId);
}
@@ -2278,6 +2298,7 @@ async function handleGetContext(params) {
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
buildTerminalToolGuidance(toolHints) +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"A session listed with connected false is still in the scope. SSH auto-reconnect keeps the same sessionId: wait and retry that session while it reconnects. Inactive disconnected or restored tabs may require user action; host_open reuses connected or connecting sessions by default and opens a connection when none is usable. " +
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
"Vault snippets, port forwarding rules/tunnels, and SFTP read/write tools are available when exposed in the tool list. " +
"Always prefer these tools over suggesting the user to do things manually.",
@@ -0,0 +1,144 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs");
isolateCliDiscoveryFile();
function loadFreshBridge() {
const bridgePath = require.resolve("./mcpServerBridge.cjs");
delete require.cache[bridgePath];
return require("./mcpServerBridge.cjs");
}
function setup(t, { sessions } = {}) {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: sessions || new Map(), electronModule: null });
bridge.setPermissionMode("auto");
return bridge;
}
test('reusing a user tab adds visibility without ownership or idle tracking', async (t) => {
const bridge = setup(t);
bridge.setVaultAgentInvoker(async () => ({ ok: true, reused: true, sessionId: 'user-tab', hostId: 'host-b', status: 'connected' }));
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'chat-b', hostId: 'host-b' });
assert.deepEqual(bridge.getScopedSessionIds('chat-b'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
const close = await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'chat-b', sessionId: 'user-tab' });
assert.equal(close.ok, false);
assert.match(close.error, /not opened by the current AI scope/);
});
test('reuse keeps the original chat owner and its idle tracking', async (t) => {
const bridge = setup(t);
const calls = [];
bridge.setVaultAgentInvoker(async (op, params) => {
calls.push({ op, params });
if (op === 'session.close') return { ok: true };
return { ok: true, reused: params.chatSessionId !== 'owner', sessionId: 'shared-tab', hostId: 'host-b', status: 'connected' };
});
for (const chatSessionId of ['owner', 'borrower']) {
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId, hostId: 'host-b' });
}
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'shared-tab', phase: 'touch' }), true);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'borrower', sessionId: 'shared-tab' })).ok, false);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'owner', sessionId: 'shared-tab' })).ok, true);
assert.equal(calls.filter(({ op }) => op === 'session.close').length, 1);
});
test('a deleted chat cannot reclaim a reused tab or revive its own scope', async (t) => {
const bridge = setup(t);
let finishOpen;
const calls = [];
bridge.updateSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'original');
bridge.setVaultAgentInvoker((op) => {
calls.push(op);
return new Promise((resolve) => { finishOpen = resolve; });
});
const opening = bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'deleted', hostId: 'host-b' });
await bridge.cleanupScopedMetadata('deleted');
bridge.mergeSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'deleted');
finishOpen({ ok: true, reused: true, sessionId: 'user-tab', status: 'connected' });
assert.equal((await opening).ok, true);
assert.deepEqual(calls, ['host.open']);
assert.deepEqual(bridge.getScopedSessionIds('deleted'), []);
assert.deepEqual(bridge.getScopedSessionIds('original'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
});
test("getContext keeps a scoped disconnected session visible with connected:false", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
// Connection dropped: renderer pushes connected:false while the tab
// auto-reconnects. The scope keeps the same sessionId.
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-reconnecting");
assert.ok(host, "reconnecting session must stay visible to the agent");
assert.equal(host.connected, false);
});
test("getContext keeps a live-map session without transport visible when scoped", async (t) => {
const bridge = setup(t, {
sessions: new Map([["sess-live", { hostname: "10.0.0.3", label: "server-c" }]]),
});
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.3",
label: "server-c",
connected: false,
hostId: "host-c",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-live");
assert.ok(host, "reconnecting live-map session must stay visible");
assert.equal(host.connected, false);
});
test("getContext still hides disconnected sessions outside the agent scope", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "foreign",
hostname: "10.0.0.9",
label: "foreign",
connected: false,
hostId: "host-x",
protocol: "ssh",
},
], "chat-other");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
assert.equal(environment.hosts.length, 0);
});
@@ -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;
@@ -560,6 +560,54 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal cancels and clears the stage before fallback creates output", async (t) => {
const controller = new AbortController();
const payload = Buffer.from("complete-download-before-cancel");
const localPath = path.join(tmpDir, "cancel-before-fallback.bin");
fs.writeFileSync(localPath, "original");
let stagedPath;
t.after(() => { if (stagedPath) fs.rmSync(stagedPath, { force: true }); });
const events = [];
sftpClients.set("scp-pre-fallback-cancel", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: {
async stat() { return { type: "file", isDirectory: false, size: payload.length }; },
async downloadFile(_remotePath, stage) {
stagedPath = stage;
await fs.promises.writeFile(stage, payload);
return { fileSize: payload.length, transferred: payload.length };
},
},
});
for (const name of ["rename", "copyFile"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (args[0] === stagedPath && String(args[1]).endsWith(".ready")) {
if (name === "copyFile") controller.abort();
throw Object.assign(new Error("injected cross-device copy refusal"), {
code: name === "rename" ? "EXDEV" : "ENOTSUP",
});
}
return original.apply(fs.promises, args);
};
}
await assert.rejects(() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-pre-fallback-cancel", remotePath: "/remote/file.bin", localPath,
abortSignal: controller.signal,
onTransferEvent: (channel) => events.push(channel),
}), (error) => {
assert.match(error.message, /cancel|abort/i);
assert.doesNotMatch(error.message, /Recovery files preserved/);
return true;
});
assert.ok(stagedPath);
assert.equal(fs.existsSync(stagedPath), false);
assert.equal(fs.readFileSync(localPath, "utf8"), "original");
assert.ok(events.includes("netcatty:transfer:cancelled"));
assert.equal(events.includes("netcatty:transfer:error"), false);
});
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
skip: process.platform === "win32",
}, async (t) => {
@@ -582,16 +630,11 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async () => {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
});
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -630,18 +673,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
const original = Buffer.from("original-before-late-change");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-directory",
@@ -685,18 +724,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-same-mode-replacement",
@@ -986,3 +1021,17 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
});
});
function interceptPreparedChmod(t, callback) {
const originalOpen = fs.promises.open;
fs.promises.open = async (filePath, ...args) => {
const handle = await originalOpen.call(fs.promises, filePath, ...args);
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready") && args[0] === "r") {
const chmod = handle.chmod.bind(handle);
handle.chmod = (mode) => callback(chmod, mode);
}
return handle;
};
t.after(() => { fs.promises.open = originalOpen; });
}
@@ -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 };
+41 -6
View File
@@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) {
}
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs");
const assertNotCancelled = options.assertNotCancelled || (() => {});
const token = crypto.randomUUID().replace(/-/g, "");
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
@@ -1050,6 +1050,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
let backedUp = false;
let committed = false;
let keepRecoveryFiles = false;
let skipReadyCleanup = false;
let preparedHandle;
let originalHandle;
let restoreProbeCreated = false;
@@ -1060,7 +1061,28 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
await fs.promises.rename(stagedPath, readyPath);
} catch (error) {
if (error?.code !== "EXDEV") throw error;
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
try {
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
} catch (copyError) {
if (!["ENOTSUP", "EOPNOTSUPP"].includes(copyError?.code)) throw copyError;
// copyFile includes metadata operations that writable GVFS mounts may
// reject. Reuse the exclusive bounded read/write publication path.
try {
await publishLocalFileExclusive(stagedPath, readyPath, assertNotCancelled, undefined, {
copyOnly: true, preserveMode: false, requireTimestamps: false,
});
} catch (fallbackError) {
// Do not remove a name this fallback never owned: copyFile may
// have left a partial, or an exclusive open may have lost a race.
skipReadyCleanup = true;
const cancelledBeforeOutput = !fallbackError.localPublicationIncomplete
&& (fallbackError.code === "ABORT_ERR" || fallbackError.message === "Transfer cancelled");
// An early cancellation needs ordinary stage cleanup, not recovery.
// Once output exists, retain the complete stage as before.
keepRecoveryFiles = !cancelledBeforeOutput;
throw fallbackError;
}
}
}
// Stamp the private prepared file before applying possibly unreadable
// destination permissions. Publication carries these times to the target.
@@ -1085,7 +1107,17 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
? validatedTarget.existingMode & 0o7777
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
if (mode !== null && mode !== appliedMode) {
await fs.promises.chmod(readyPath, mode);
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
try {
await preparedHandle.chmod(mode);
} catch (error) {
if (!isMetadataUnsupportedError(error)
|| ((await preparedHandle.stat()).mode & 0o7777) !== mode) throw error;
}
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
appliedMode = mode;
continue;
}
@@ -1131,7 +1163,9 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
assertNotCancelled();
let publishedIdentity = null;
try {
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, {
preserveMode: appliedMode !== null, requireTimestamps: false,
}) ?? null;
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("Local download target changed during replacement", { cause: error });
@@ -1144,12 +1178,13 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
committed = true;
// Hand the published inode identity to the caller for descriptor-based
// metadata stamping after publication.
options.onCommit?.(publishedIdentity, localMtimePrepared);
options.onCommit?.(publishedIdentity, localMtimePrepared && publishedIdentity?.timestampsPreserved !== false);
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(stagedPath).catch(() => {});
} catch (error) {
if (committed) throw error;
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
if (backedUp && !keepRecoveryFiles) {
try {
// The pathname may have changed between open and rename. Never copy
@@ -1179,7 +1214,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
if (backedUp) failure.remoteBackupPath = backupPath;
throw failure;
}
await fs.promises.unlink(readyPath).catch(() => {});
if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {});
throw error;
} finally {
await preparedHandle?.close().catch(() => {});
@@ -0,0 +1,189 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const bridge = require("./transferBridge.cjs");
const temp = require("./tempDirBridge.cjs");
const unsupported = (code = "ENOTSUP") => Object.assign(new Error(`Injected ${code}`), { code });
// Real files and handles; only unavailable mount operations and virtual mode
// reporting are simulated. This is not a real Linux/GVFS acceptance test.
async function mountedDestination(t, options = {}) {
const root = fs.mkdtempSync(`${temp.getTempFilePath("gvfs-publish")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const stage = path.join(root, "stage.part");
const target = path.join(root, "target");
const payload = Buffer.alloc(2 * 1024 * 1024 + 17, 173);
fs.writeFileSync(stage, payload, { mode: 0o644 });
if (options.existingMode !== undefined) {
fs.writeFileSync(target, "original");
fs.chmodSync(target, options.existingMode);
}
const calls = [];
let cancelled = false;
for (const name of ["rename", "copyFile", "link", "open"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (name === "rename" && args[0] === stage) throw unsupported("EXDEV");
if (name === "copyFile") {
calls.push("copyFile");
if (options.readyCollision) fs.writeFileSync(args[1], "other writer");
if (options.cancelBeforeFallback) cancelled = true;
if (options.copyError !== false) throw unsupported(options.copyError || "ENOTSUP");
}
if (name === "link") throw unsupported();
const result = await original.apply(fs.promises, args);
if (name !== "open" || args[0] === stage) return result;
const created = args[1] === "wx";
const virtualMode = args[0] === target ? options.finalVirtualMode ?? options.virtualMode : options.virtualMode;
if (created && virtualMode !== undefined) fs.chmodSync(args[0], virtualMode);
result.chmod = async () => { calls.push("chmod"); throw unsupported(options.metadataError); };
result.utimes = async () => { calls.push("utimes"); throw unsupported(options.metadataError); };
if (created) {
const final = args[0] === target;
calls.push(final ? "final-open" : "ready-open");
const write = result.write.bind(result);
result.write = async (buffer, offset, length, position) => {
if (options.writeError && final) throw unsupported(options.writeError);
const written = await write(buffer, offset, Math.min(length, 32768), position);
if (options.cancel && !final) cancelled = true;
return written;
};
const close = result.close.bind(result);
result.close = async () => {
await close();
if (options.closeError === (final ? "final" : "ready")) throw unsupported("EIO");
};
}
return result;
};
}
const run = () => bridge._promoteLocalTransferForTests(stage, target, {
sourceSoftIdentity: { mtimeMs: 1700000000000 },
validateTarget: async () => ({
targetIdentity: options.existingMode === undefined ? "missing" : undefined,
existingMode: options.existingMode,
}),
assertNotCancelled: () => {
if (cancelled || (options.cancelBeforeFinal && !fs.existsSync(target))) throw unsupported("ABORT_ERR");
},
onCommit: (_identity, timestampsPreserved) => { calls.push(["commit", timestampsPreserved]); },
});
return { root, stage, target, payload, calls, run };
}
for (const [virtualMode, copyError, metadataError] of [[0o644, "ENOTSUP"], [0o700, "EOPNOTSUPP"], [0o700, "ENOTSUP", "EPERM"], [0o700, "ENOTSUP", "EACCES"]]) {
test(`new mounted destination accepts creation policy ${virtualMode.toString(8)} with ${metadataError || "ENOTSUP"} through both copy stages`, async (t) => {
const setup = await mountedDestination(t, { virtualMode, copyError, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
assert.ok(setup.calls.includes("ready-open"));
assert.ok(setup.calls.includes("final-open"));
assert.deepEqual(setup.calls.at(-1), ["commit", false]);
});
}
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`existing mounted file keeps matching virtual permissions: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o700, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o700);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
});
test(`refused chmod cannot broaden an existing destination: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o600, metadataError });
await assert.rejects(setup.run, { code: metadataError });
assert.equal(fs.readFileSync(setup.target, "utf8"), "original");
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o600);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const copyError of ["EACCES", "EPERM", "ENOSPC", "EIO", "EEXIST", "EINVAL"]) {
test(`copyFile ${copyError} remains a failure`, async (t) => {
const setup = await mountedDestination(t, { copyError });
await assert.rejects(setup.run, { code: copyError });
assert.equal(fs.existsSync(setup.target), false);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.includes("ready-open"), false);
});
}
for (const failure of [{ cancel: true }, { closeError: "ready" }, { closeError: "final" }, { writeError: "ENOSPC" }, { writeError: "EPERM" }, { writeError: "EACCES" }]) {
test(`copy failure preserves original and complete stage: ${JSON.stringify(failure)}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, ...failure });
await assert.rejects(setup.run, /Recovery files preserved/);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.equal(fs.readFileSync(backup ? path.join(setup.root, backup) : setup.target, "utf8"), "original");
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
test("failed copy followed by exclusive-open collision leaves that entry intact", async (t) => {
const setup = await mountedDestination(t, { readyCollision: true });
await assert.rejects(setup.run, /Recovery files preserved/);
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(fs.existsSync(setup.target), false);
});
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const metadataError of ["EPERM", "EACCES"]) {
test(`final copy cannot broaden an existing destination after chmod ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, {
existingMode: 0o644, virtualMode: 0o644, finalVirtualMode: 0o700, metadataError,
});
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
for (const readyCollision of [false, true]) {
test(`cancellation before fallback output remains cancellation, existing ready=${readyCollision}`, async (t) => {
const setup = await mountedDestination(t, { cancelBeforeFallback: true, readyCollision });
const originalLstat = fs.promises.lstat;
const originalUnlink = fs.promises.unlink;
t.after(() => { fs.promises.lstat = originalLstat; fs.promises.unlink = originalUnlink; });
for (const [name, original] of [["lstat", originalLstat], ["unlink", originalUnlink]]) {
fs.promises[name] = async (...args) => {
assert.equal(String(args[0]).endsWith(".ready"), false, "no destination probe/cleanup after early cancellation");
return original.apply(fs.promises, args);
};
}
await assert.rejects(setup.run, (error) => {
assert.equal(error.code, "ABORT_ERR");
assert.equal(Boolean(error.recoveryFailed), false);
return true;
});
assert.equal(setup.calls.includes("ready-open"), false);
assert.equal(fs.existsSync(setup.target), false);
if (readyCollision) {
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
} else assert.deepEqual(fs.readdirSync(setup.root), ["stage.part"]);
});
}
+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",
);
}
});
+1 -1
View File
@@ -48,7 +48,7 @@ const VAULT_CAPABILITIES = [
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description:
"Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
"Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
// Sidebar Catty is scoped to already-open terminals/workspaces and must not
// expand that scope mid-turn. Keep host_open for MCP / CLI / global agent.
agentKinds: [AGENT_KINDS.GLOBAL],
+2 -1
View File
@@ -83,6 +83,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
"vault.host.list": {},
"vault.host.open": {
hostId: { type: "string", description: "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." },
newTab: { type: "string", optional: true, description: "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session." },
},
"vault.hosts.create": {
hosts: {
@@ -420,7 +421,7 @@ const MODEL_DESCRIPTION_HINTS = Object.freeze({
"vault.host.notes.set":
"Host metadata notes on a saved host — not Vault → Notes sidebar entries. Prefer vault_notes_create/update when the user wants vault notes they can open in the Notes sidebar.",
"vault.host.open":
"Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"vault.host.list":
"Use when the user names a saved Netcatty host but get_environment has no matching live session. Resolve the hostId here, call host_open, then call get_environment to obtain the new sessionId.",
"vault.host.import":
@@ -47,6 +47,7 @@ function createVaultService(ctx = {}) {
return invokeVaultAgent("host.open", {
hostId: params.hostId,
chatSessionId: params.chatSessionId,
newTab: params.newTab,
});
},
createHosts: async (params = {}) => {
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more