Compare commits

...
Author SHA1 Message Date
bincxz fe7f5029af fix(ai): use neutral MCP text for silent terminal_execute success
Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:10:48 +08:00
bincxz 1d0762d250 fix(ai): stop OpenCode turns dying on terminal tool failures
OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718
2026-08-05 10:58:39 +08:00
陈大猫 29df6216ed Merge pull request #2700 from binaricat/fix/remove-unused-eslint-disable-monaco-paste
fix(lint): remove unused eslint-disable in monaco paste test
2026-08-04 10:56:34 +08:00
bincxz c8c24a5c5c fix(lint): remove unused eslint-disable in monaco paste test
The no-dynamic-delete rule no longer flags this delete, so the disable directive is unused.
2026-08-04 10:56:17 +08:00
陈大猫 f35f578cae Merge pull request #2693 from binaricat/fix/2684-keyword-highlight-flicker
fix: prevent keyword highlights flashing on Enter
2026-08-04 10:46:24 +08:00
陈大猫 d44af5f4a5 Merge pull request #2695 from binaricat/fix/2677-macos-bitwarden-agent-forwarding
Fix macOS Bitwarden SSH agent forwarding
2026-08-04 10:09:32 +08:00
bincxz b261869acc fix(terminal): resume Enter work after full refresh 2026-08-04 03:40:41 +08:00
bincxz d7ec779a7d fix(terminal): pause Enter scans under pressure 2026-08-04 03:32:52 +08:00
bincxz af1e0ce5ad fix(terminal): preserve Enter refresh progress 2026-08-04 03:25:07 +08:00
bincxz 8b40c187f7 fix(terminal): reschedule queued work after Enter 2026-08-04 02:55:27 +08:00
bincxz 1961e87ff9 fix(terminal): detect saturated output scrolls 2026-08-04 02:31:07 +08:00
bincxz 04479d1c4f fix: retain automatic login agent warning 2026-08-04 02:22:15 +08:00
bincxz db24fd115e fix(terminal): bound refresh latency during output 2026-08-04 02:21:13 +08:00
bincxz 39458a84f0 fix: separate agent availability warnings 2026-08-04 02:11:55 +08:00
bincxz b60fad2080 fix(terminal): recognize delayed output scrolls 2026-08-04 02:06:41 +08:00
bincxz 072bbf24ad fix: preserve standard agent authentication 2026-08-04 02:01:12 +08:00
bincxz 5d8409580a fix(terminal): cancel Enter scroll work before pressure 2026-08-04 01:59:18 +08:00
bincxz 11850a0dac fix(terminal): separate highlight stability from pruning 2026-08-04 01:52:49 +08:00
bincxz 8b7d1d7647 fix: preserve channel connection reuse 2026-08-04 01:51:22 +08:00
bincxz a3d071f398 fix(terminal): defer pruning through Enter output 2026-08-04 01:47:47 +08:00
bincxz 83cd1890ac fix(terminal): retain Enter protection until output idle 2026-08-04 01:42:24 +08:00
bincxz 38bce21a94 fix(terminal): wait for Enter output movement 2026-08-04 01:34:47 +08:00
bincxz 6e73cec109 fix: refresh forwarding agent selection 2026-08-04 01:32:03 +08:00
bincxz aef1678af8 fix(terminal): retain submitted Enter until output 2026-08-04 01:25:50 +08:00
bincxz bed3e03fe2 fix(terminal): prefer Enter writes over output scrolls 2026-08-04 01:21:49 +08:00
bincxz 74a23c335b fix: isolate login and forwarding agents 2026-08-04 01:18:38 +08:00
bincxz ac7288d65f fix(terminal): preserve Enter pruning across scan frames 2026-08-04 01:15:12 +08:00
bincxz 9a5ef5c84c fix(terminal): defer highlight pruning after Enter 2026-08-04 01:06:50 +08:00
bincxz 9400e3731b fix(terminal): bound keyword highlight retention 2026-08-04 01:05:40 +08:00
bincxz cb6c65fd37 fix: separate agent login from forwarding 2026-08-04 01:05:26 +08:00
bincxz 28738881ff fix(terminal): prevent keyword highlight flicker on Enter 2026-08-04 01:05:26 +08:00
陈大猫 13aa4a8c67 Merge pull request #2694 from binaricat/fix/2682-cursor-keyword-highlight
fix(terminal): keep cursor-row background behind keyword highlights
2026-08-04 00:59:53 +08:00
bincxz 3b6ca6f803 fix: select usable macOS agent for forwarding 2026-08-04 00:55:00 +08:00
bincxz 121a1ed8be fix(terminal): keep keyword highlights on cursor line
Cursor-line highlighting skipped keyword foreground ranges, exposing the terminal default background. Paint the row continuously while leaving keyword foreground decorations intact.
2026-08-04 00:52:11 +08:00
陈大猫 8ac48c3e51 Merge pull request #2691 from binaricat/cursor/issue-2689-30798978737
fix(settings): stop terminal font size oscillating across windows
2026-08-04 00:41:06 +08:00
bincxz 76c2463b3d fix(settings): repair stale font size persistence 2026-08-04 00:20:54 +08:00
bincxz 56bef275f5 fix(settings): converge terminal font size updates 2026-08-04 00:01:34 +08:00
陈大猫 3dbd2213fa Merge pull request #2690 from yintian710/main
feat(SnippetsManager): 添加批量删除代码片段功能
2026-08-03 23:57:46 +08:00
bincxz 8ef87f2f16 fix(snippets): report bulk deletion accurately 2026-08-03 23:50:32 +08:00
bincxz 84125bcdcb fix(snippets): clean host bindings on bulk delete 2026-08-03 23:46:07 +08:00
netcatty-bot c6313b8b02 fix(#2689): automated Cursor CLI fix 2026-08-03 09:16:55 +00:00
陈大猫 bbef1d2d99 Merge pull request #2686 from binaricat/worktree/brave-harbor-2120
feat: add nested split terminal side panels
2026-08-03 17:07:58 +08:00
yintian 0cff7a3e52 test(snippet): 添加片段批量删除功能的测试用例
- 创建国际化多语言验证测试确保所有语言支持批量删除文案
- 实现片段选择状态管理单元测试验证删除逻辑
- 添加边界情况测试处理不存在的选择ID
- 验证删除操作不会修改原始片段数组
- 确保空选择集返回完整副本避免副作用
2026-08-03 17:00:12 +08:00
bincxz 21022d6b92 fix: include split divider in pane minimum 2026-08-03 16:46:42 +08:00
bincxz a50222dc07 fix: preserve nested pane minimums 2026-08-03 16:44:07 +08:00
yintian ce0b8f85c3 feat(SnippetsManager): 添加批量删除代码片段功能
- 引入 removeSelectedSnippets 函数处理批量删除逻辑
- 扩展 deleteTarget 状态类型以支持批量删除操作
- 实现 requestDeleteSelectedSnippets 函数请求删除选中项
- 在确认删除函数中添加对批量删除类型的处理
- 添加删除按钮到多选模式的操作栏中
- 更新删除确认对话框以支持批量删除的标题和描述
- 添加英文、俄文、简体中文和繁体中文的批量删除相关翻译
2026-08-03 16:38:02 +08:00
bincxz 764c7ea4c7 fix: clamp side panel to terminal surface 2026-08-03 16:37:30 +08:00
bincxz 1a61ec86ef fix: close side panel review gaps 2026-08-03 16:32:21 +08:00
bincxz 228d992671 fix: address split panel review findings 2026-08-03 16:25:22 +08:00
bincxz 523dcd80c8 fix: harden split side panel interactions 2026-08-03 16:14:29 +08:00
bincxz 093728c31f fix: refine AI composer streaming 2026-08-03 15:34:42 +08:00
陈大猫 d11fcd80ca Merge pull request #2671 from binaricat/fix/windows-explorer-context-menu-2651-2655
fix(windows): Explorer Open-in-Netcatty toggle and launch fix
2026-08-03 15:33:42 +08:00
bincxz 0950efb41b feat: resize AI chat composer 2026-08-03 15:18:39 +08:00
bincxz 7cfe82aacc fix: compact agent selector menu 2026-08-03 14:09:19 +08:00
bincxz 97890d6f84 fix: unify side panel toolbars 2026-08-03 14:01:24 +08:00
bincxz 33a1e5852c fix: polish split side panel layout 2026-08-03 13:50:25 +08:00
bincxz 9639a26568 fix: harden split side panel lifecycle 2026-08-03 12:46:06 +08:00
bincxz fb3cb2a263 fix(windows): fall back to HKCU when machine Explorer verbs cannot be repaired
If residual/stale HKLM keys cannot be rewritten without elevation, register
per-user HKCU verbs instead so Settings can still enable Open in Netcatty
for portable and unelevated installs.
2026-08-03 12:35:39 +08:00
bincxz add0f33cd5 feat: add split terminal side panels 2026-08-03 12:34:49 +08:00
bincxz 1289383169 fix(windows): track dev appArgs in Explorer menu warm-start identity
Persist and compare development app entry args so moving a checkout
re-registers Explorer shell commands instead of keeping a stale
electron.exe + old app path launch line.
2026-08-03 12:28:35 +08:00
bincxz 4974e22879 fix(windows): restore hidden portable Explorer verbs on failed enable
Clear only ProgrammaticAccessOnly when unsuppressing keys that still have
commands, and unsuppress after a failed HKLM refresh so preference-write
rollback cannot leave the only working portable integration hidden.
2026-08-03 12:23:39 +08:00
bincxz 61bc1f1102 fix(windows): roll back Explorer suppression without deleting install verbs
When only one ProgrammaticAccessOnly write succeeds, remove just that
value (or a newly created empty key) instead of deleting the whole HKCU
shell subtree that may still hold a working portable command.
2026-08-03 12:15:13 +08:00
bincxz 0d7b9839bc fix(windows): revalidate Explorer verbs before enabled warm-start
When the saved preference says enabled, still confirm both shell verbs
remain registered; otherwise re-apply so interrupted installer rewrites
or partial cleanup cannot leave a permanently missing menu entry.
2026-08-03 12:10:25 +08:00
bincxz 7657de7c01 fix(windows): suppress machine Explorer verbs without deleting HKCU first
When HKLM registration exists, disable only writes ProgrammaticAccessOnly.
Deleting portable HKCU install verbs before a failed suppression left users
with no working Explorer integration and an unrepairable machine path.
2026-08-03 12:04:08 +08:00
bincxz 925be73b1e fix(windows): recheck HKCU residuals after Explorer menu probe
Probe markers must not skip residual scans: a later per-user NSIS install
writes HKCU verbs, not HKLM, and must still be repaired/adopted on launch.
2026-08-03 11:58:39 +08:00
bincxz 3dfb100c50 fix(windows): probe default-off without preference and fix dev shell cmds
Record a separate probe marker for ZIP/portable no-verb defaults so a
shared AppData profile cannot suppress a later NSIS install. Register
development Explorer verbs as electron.exe + absolute app entry.
2026-08-03 11:52:28 +08:00
bincxz 259a5af6f6 fix(windows): report residual Explorer verbs and cache default-off
After a failed machine repair, report any surviving shell verb as enabled
so Settings can disable it. Persist enabled:false for ZIP/portable defaults
so warm starts skip repeated registry probes.
2026-08-03 11:43:53 +08:00
bincxz 953ff61328 fix(windows): refresh HKLM before clearing portable Explorer verbs
When a partial machine registration exists, keep working portable HKCU
verbs until both HKLM shell entries are current, so unelevated enable
attempts cannot destroy the user's only working context-menu path.
2026-08-03 11:33:47 +08:00
bincxz e014098695 fix(windows): require HKCU cleanup and roll back failed Explorer toggles
Treat residual portable HKCU verbs as a hard enable failure when HKLM
exists, require suppression cleanup on user-scope enable, and restore
the previous preference when apply partially mutates the registry.
2026-08-03 11:28:05 +08:00
bincxz be9f53bf3f fix(windows): clear HKCU Explorer verbs when machine install exists
Codex P2 on #2671: portable/ZIP builds leave real HKCU shell verbs that
shadow later all-users HKLM registrations. When machine keys exist,
delete the per-user Netcatty verbs so Explorer uses the current install.
2026-08-03 11:24:50 +08:00
陈大猫 e9de0b1548 Merge pull request #2674 from binaricat/fix/sftp-picker-sudo-connected-2656
fix(sftp): show sudo hosts under Connected in host picker
2026-08-03 11:24:04 +08:00
bincxz 604cbbdf20 fix(windows): roll back partial Explorer menu suppression writes
Codex P2 on #2671: if only one ProgrammaticAccessOnly value is written
during disable, delete the partial key so disable never leaves a single
hidden verb while the other remains visible.
2026-08-03 11:14:33 +08:00
bincxz a109c3409b fix(sftp): do not mark sudo Connected opens as reused
Omit sourceSessionId for sftpSudo hosts in the picker and normalize the
browse connect path so reusedConnection (spinner/overlay/logs) matches the
dedicated sudo open. Addresses Codex review on #2674.
2026-08-03 11:12:10 +08:00
bincxz a010454e10 fix(windows): restore suppression on failed enable and detect residual verbs
Codex P2s on #2671: remove treats any leftover runnable verb as still
enabled, and install restores ProgrammaticAccessOnly when enabling fails
after clearing a prior per-user suppression.
2026-08-03 11:09:58 +08:00
陈大猫 f8939decd7 Merge pull request #2675 from binaricat/fix/sftp-stale-conflict-dialog-2636
fix(sftp): stop skipped conflicts from reopening after restart
2026-08-03 11:09:51 +08:00
bincxz ed2fa70f5c fix(windows): repair partial Explorer menu residuals on first launch
Codex P2 on #2671: when no preference exists but a single leftover shell
verb remains, still run install/repair so the Settings toggle cannot stay
off while Explorer still shows one Open-in-Netcatty entry.
2026-08-03 11:04:52 +08:00
bincxz efd92fdbe4 fix(sftp): stop skipped conflicts from reopening after restart
Skip used to mark transfers cancelled without clearing conflict metadata, so reopening SFTP rehydrated the dialog while the transfer center looked empty. Clear conflict on skip/stop, only restore attention conflicts in the dialog, and strip stale payloads when loading persisted history.

Fixes #2636
2026-08-03 11:01:46 +08:00
bincxz b893ed2b06 fix(windows): fail Explorer menu install when HKLM verbs stay stale
Codex P2 on #2671: unelevated refreshes must not report success merely
because non-empty HKLM commands exist. Require writes to succeed or both
verbs to already match the expected executable/command contract.
2026-08-03 11:00:23 +08:00
bincxz e7d41c13c8 fix(windows): require both Explorer verbs to be suppressed
Codex P2 on #2671: treat the integration as user-suppressed only when
both folder and background keys have ProgrammaticAccessOnly, so a partial
suppress write cannot hide one menu while claiming the feature is off.
2026-08-03 10:55:14 +08:00
bincxz 41344d83f7 fix(sftp): show sudo hosts under Connected in host picker
List open SSH terminals in the SFTP Connected section even when sftpSudo
is enabled. Connection still opens a dedicated sudo SFTP channel and does
not reuse the terminal shell. Closes #2656.
2026-08-03 10:52:44 +08:00
bincxz eb79e97626 fix(windows): reassert disabled Explorer menu and require both verbs
Address Codex P2s on #2671: disabled preferences re-suppress on every
startup so NSIS updates cannot resurrect the menu, and registration is
only considered complete when both folder and background commands exist.
2026-08-03 10:50:03 +08:00
bincxz e3241f8b92 fix(windows): treat Explorer shell verbs without command as inactive
Codex P2 on #2671: a partially written registry verb (key exists, no
command value) must not count as an active menu registration, so install
and Settings toggle state stay honest and repair can still run.
2026-08-03 10:44:42 +08:00
陈大猫 71e7c60aaa Merge pull request #2670 from binaricat/fix/sftp-download-growing-files-2653
fix(sftp): allow downloading append-only growing files
2026-08-03 10:44:24 +08:00
bincxz 2f6b395cf3 fix(sftp): version prefix fingerprints and empty-prefix digests
Persist download fingerprints as sha256:pN:hex so coverage is explicit,
treat planned zero bytes as the empty digest, and fall back to a full-file
check for legacy bare sha256:hex identities saved by older builds.
2026-08-03 10:34:43 +08:00
bincxz 49065c1b5e fix(sftp): preserve zero-byte totalBytes on standalone resume
Standalone dedicated reconnect was still coercing totalBytes=0 to
undefined via ||, so the bridge re-statted a grown remote and abandoned
the empty snapshot plan. Keep finite zeros in the stream payload.
2026-08-03 10:25:39 +08:00
bincxz b7359fefa6 fix(windows): re-register Explorer menu when portable path moves
Persist the last-applied executablePath in the preference record and
re-apply shell verbs when it differs from the current launcher path so
renamed/moved portable builds keep Open-in-Netcatty working.
2026-08-03 10:22:00 +08:00
bincxz 4d612668d5 fix(sftp): fail closed on compact growth; keep explicit zero plans
Drop growth-aware compact identity experiments that either lost rewrite
proof or needed renderer node:fs. Compact entries keep full size+mtime
identities (retransfer on remote growth). Preserve explicit totalBytes=0
through bridge preflight so empty download snapshots do not re-stat into
a grown remote size.
2026-08-03 10:19:31 +08:00
bincxz b4d40ab67a fix(windows): register portable launcher path for Explorer menu
Codex P2 on #2671: electron-builder portable apps expose the stable
launcher via PORTABLE_EXECUTABLE_FILE while process.execPath points at a
temp unpack that is deleted on exit. Resolve that path for shell verbs
and bump the preference schema so existing installs re-register once.
2026-08-03 10:17:41 +08:00
bincxz 2d20fe9834 fix(sftp): keep planned size proof for download resume identities
Path-only compact identities were too weak for rewritten/truncated files.
Download trees now key entry identity to path+planned size (omit mtime),
revalidate covered compact entries against local destination sizes, and
preserve explicit zero-byte snapshot plans through resume validation.
2026-08-03 10:12:22 +08:00
bincxz 072af9caa3 fix(windows): keep HKLM Explorer verbs for all users on toggle off
Address Codex P2 on #2671: the Settings toggle is per-user, so disable
only clears HKCU and suppresses machine verbs via ProgrammaticAccessOnly
instead of deleting HKLM (even when elevated). Also persist a schemaVersion
so healthy warm starts skip registry work after the first migration.
2026-08-03 10:11:26 +08:00
bincxz b965a031f2 fix(windows): skip Explorer registry rewrites when verbs are current
Codex P2 on #2671: warm starts no longer spawn a full reg.exe write pass
when installer/user shell verbs already match the expected command, label,
and icon. Still rewrites stale --open-terminal-path entries on upgrade.
2026-08-03 10:03:59 +08:00
bincxz df540cca4f fix(sftp): address Codex P2s on growing download resume
Preserve the planned snapshot size for interrupted directory download
children, use path-stable compact identities for download trees so
append-only growth does not invalidate covered prefixes, and treat
zero-byte planned snapshots as complete in the sequential fallback.
2026-08-03 10:03:46 +08:00
bincxz 8533b3f4c0 fix(sftp): harden growing-download finish and resume paths
Codex review on #2670: accept remote growth only after the full planned
prefix still matches the staged file, and skip the sequential stream when
the checkpoint already covers the snapshot so grown logs do not resume
from the old EOF.
2026-08-03 09:54:58 +08:00
bincxz d6d268e5c7 fix(windows): suppress HKLM Explorer menu per-user without elevation
Address Codex review on #2671: when all-users (HKLM) shell verbs cannot be
deleted unelevated, hide them for the current user via ProgrammaticAccessOnly
instead of failing the Settings toggle. Also stop mirroring machine verbs into
HKCU so uninstall does not leave a broken Open-in-Netcatty entry.
2026-08-03 09:51:36 +08:00
github-actions[bot]andgithub-actions[bot] 3dbc4bb9bb chore: update star history charts [skip ci] (#2672)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-03 00:24:14 +00:00
bincxz 61281d79d8 fix(windows): toggle Explorer Open-in-Netcatty menu and harden launch
Add a Settings > System switch to enable/disable the Explorer folder
context menu, and fix shell launch so Chromium does not swallow
--open-terminal-path (args after --, =path form, trailing-dot roots).

Closes #2651
Closes #2655
2026-08-03 01:22:03 +08:00
陈大猫 3e347ccb41 Merge pull request #2668 from binaricat/fix/2661-snippet-find-paste
fix(snippets): paste into Monaco find widget, not script body
2026-08-03 01:18:31 +08:00
bincxz 506769c421 fix(snippets): abort find-widget paste if focus left mid-read
Address Codex review: after the async clipboard read, only insert when
the find input is still document.activeElement and still connected, so
a delayed paste cannot steal focus or hit the editor body.
2026-08-03 01:04:51 +08:00
bincxz fab439a646 fix(sftp): allow downloading append-only growing files
SFTP downloads take a fixed size snapshot at start, then rejected the
transfer if the remote file grew before completion. Live logs and other
append-only sources commonly hit that post-check and failed with a size
mismatch. Accept remote growth when the planned prefix was transferred,
and keep resume identity keyed to that snapshot so pause/resume still works.

Closes #2653
2026-08-03 01:02:13 +08:00
陈大猫 1d879443ec Merge pull request #2669 from binaricat/fix/sftp-conflict-dialog-button-layout
fix(sftp): keep conflict dialog actions on one row
2026-08-03 01:01:02 +08:00
bincxz 8174971c3c fix(sftp): keep conflict dialog actions on one row
Widen the SFTP conflict dialog and prevent footer buttons from wrapping so Stop/Skip/Duplicate/Merge/Replace stay on a single line.
2026-08-03 01:00:48 +08:00
bincxz 421dcb8230 fix(snippets): paste into Monaco find widget, not script body
Custom Ctrl/Cmd+V handling for Electron overwrote paste while the
snippet script editor find bar had focus, so search text landed in the
script. Route find-widget paste through shared helpers and reuse them
in the text editor.

Fixes #2661
2026-08-03 00:57:24 +08:00
陈大猫 b4fbedf827 Merge pull request #2664 from binaricat/cursor/issue-2663-30735043928
fix(ssh): trigger macOS Local Network prompt from main process
2026-08-03 00:44:14 +08:00
bincxz 406fe2135a fix(ssh): harden macOS Local Network probe for LAN SSH
Bound the main-process TCC probe to 3s, only treat real private IPv4/IPv6
literals as LAN, prefer proxy as first hop when set, and annotate
unreachability using either the target or the first hop. Closes the
remaining merge blockers for #2663.
2026-08-03 00:36:03 +08:00
陈大猫 a2b922eb92 Merge pull request #2666 from hang666/feat/grok-build-agent
feat(ai): Grok Build managed agent
2026-08-03 00:24:58 +08:00
陈大猫 fbb09d0100 Merge pull request #2667 from binaricat/codex/catty-context-controls
feat(ai): add Catty context controls
2026-08-03 00:18:47 +08:00
bincxz 9c9024eef1 fix(ai): allow Catty /compact without a message floor
Show manual compact for any Catty session with a configured model, not
only when enough history exists for a large head. External agents still
do not get the command.
2026-08-03 00:13:01 +08:00
bincxz 87655e0b85 fix(ai): use tool-safe compact boundaries
Compute force /compact UI split with the same tool-pair safety as SDK
compaction, and only enable canCompact when a durable head exists.
2026-08-02 23:40:15 +08:00
bincxz e77f99aa78 fix(ai): harden compact UX and durable boundary
Gate /compact behind canCompact so the composer is not cleared as a
silent no-op, hide the usage ring until a real context snapshot arrives,
and summarize only the UI head that the persisted compaction boundary
will drop. Drop unused clearSessionMessages surface and add slash-command
regression tests.
2026-08-02 23:31:06 +08:00
bincxz 222566f541 fix(ai): drop /clear and rename /compress to /compact
Keep only the context usage ring and a manual /compact command so the
PR stays scoped to the original request without session-clear edge cases.
2026-08-02 23:16:15 +08:00
hang666 f17d96ba1b fix(ai): normalize Grok plan updates to shared activity shape
Emit plan items as { text, completed } with top-level running|completed status so sdkAgentAdapter and the activity UI can render labels and completion instead of empty rows stuck on running.
2026-08-02 22:08:53 +08:00
hang666 97b773ef89 fix(ai): fail incomplete Grok turns and stop swallowing ACP prompt errors
Remove the async rpc.request hop that let promptDone win Promise.race on session/prompt JSON-RPC errors. Gate emitDone on turnCompleted, treat any incomplete process close (including exit 0) as failure, and align streaming-json toolResult emission with ACP when rawOutput is present.
2026-08-02 21:39:36 +08:00
hang666 0436dc9556 fix(ai): use ACP optionIds and fail signal-killed unfinished Grok turns
Permission replies must select an optionId offered in params.options instead of inventing allow-once. Process close(null, signal) before turnCompleted is a failure for both ACP and streaming-json drivers.
2026-08-02 21:18:54 +08:00
hang666 5ee42cf34b fix(ai): handle Grok ACP stdin EPIPE like other bridges
Attach a stdin error listener so async EPIPE after peer exit is not an
unhandled stream error. Treat EPIPE/ERR_STREAM_DESTROYED as benign
(processErrorGuards/terminalBridge); fail the turn only for other mid-turn
write errors.
2026-08-02 20:52:15 +08:00
hang666 72b4f2319b fix(ai): treat partial Grok streams with nonzero exit as failures
Only turnCompleted (headless end / ACP prompt success) may ignore teardown
exit codes. streamedAssistantText must not suppress errors, or mid-response
crashes look successful.
2026-08-02 20:40:34 +08:00
hang666 47b2094d8b fix(ai): emit Grok ACP usage synchronously on prompt result
Root cause of Token ~1: onPromptComplete resolved promptDone in the same
tick as session/prompt, so Promise.race often discarded the result and the
UI fell back to estimated usage (prompt length / 4). Emit usage when the
prompt response is handled, and map live Grok _meta.usage fields including
cachedReadTokens.
2026-08-02 20:27:24 +08:00
hang666 73780dddab fix(ai): expire Grok runtime siblings and emit ACP prompt usage
Drop the inactive acp/streaming-json in-memory session when switching
runtimes so switch-back cannot resume a pre-switch thread without
intervening turns. Forward optional session/prompt usage to emitter.usage.
2026-08-02 19:52:02 +08:00
hang666 2453b9f1a8 fix(ai): ignore Grok process exit after successful prompt
Tool-only turns leave no assistant text; Windows teardown often exits with
code 1. Mark turnCompleted when session/prompt succeeds so close does not
emitError or skip emitDone.
2026-08-02 19:36:56 +08:00
hang666 ea8ccce690 fix(ai): spawn Grok CLI through prepareCommandForSpawn
Windows .cmd/.bat installer shims fail with spawn EINVAL when launched
without shell. Route ACP, headless, and models spawns through the same
helper other managed agents use.
2026-08-02 18:42:06 +08:00
hang666 6888a79b03 fix(ai): seed Grok history when ACP resume falls back to session/new
When a stored session id cannot resume/load, still inject renderer history
into the new session prompt so multi-turn context is not dropped. Successful
resume/load paths remain unseeded to avoid stacked prior replies.
2026-08-02 18:09:01 +08:00
hang666 ae5b648fdc fix(ai): remove obsolete model 2026-08-02 17:47:15 +08:00
bincxz 3b0a519c0f fix(ai): constrain command tooltips 2026-08-02 17:17:11 +08:00
bincxz 9d0d02b528 fix(ai): keep permission menu within viewport 2026-08-02 17:02:50 +08:00
bincxz 666f96d587 fix(ai): tune context ring sizing 2026-08-02 16:55:53 +08:00
bincxz f19c1b7a26 fix(ai): refine context ring placement 2026-08-02 16:54:20 +08:00
bincxz 4c70c5be13 fix(ai): match Codex context ring design 2026-08-02 16:52:15 +08:00
hang666 8aace6c1ce fix(ai): place Grok ACP global flags before agent subcommand
--disallowed-tools and --no-auto-update are top-level grok options; after
agent the live CLI rejects them and MCP-mode ACP turns cannot start.
2026-08-02 16:42:58 +08:00
hang666 77fed032f5 fix(ai): close Grok dual-runtime resume, auth, and tool-policy gaps
Prefer ACP session/resume (then load/new) without replaying history into the
current turn, authenticate when Grok advertises methods, isolate acp vs
streaming-json session identities, and apply MCP local-tool lockdown plus
--no-auto-update on both transports.
2026-08-02 16:35:37 +08:00
hang666 b2a379a368 fix(ai): stop Grok ACP turns from replaying prior replies
Suppress session/update emissions until session/prompt starts so
session/load history replay does not fill the current assistant bubble.
Also treat Grok like CodeBuddy: when a resume session id exists, do not
also inject renderer conversation history into the prompt.
2026-08-02 16:14:28 +08:00
hang666 129aaddb40 feat(ai): add Grok ACP vs streaming-json runtime switch
Expose a Settings toggle on the Grok Build card (like Codex App Server)
to choose ACP agent stdio or the original headless streaming-json path.
Persist grokRuntime on ExternalAgentConfig and pass it into the driver
via NETCATTY_GROK_RUNTIME on the agent env.
2026-08-02 16:05:20 +08:00
hang666 97b0dd4ebc fix(ai): pass Grok ACP MCP env as name/value pairs
Grok agent stdio session/new rejects plain object env (Invalid params /
McpServer enum). Emit type:stdio servers with env as [{name,value},...]
matching the live protocol, and assert the shipped shape in unit tests.
2026-08-02 15:52:27 +08:00
hang666 3ea4286ed5 feat(ai): add Grok Build ACP agent-stdio turn path
Default Grok turns to grok agent stdio (JSON-RPC ACP): initialize,
session/new with Netcatty mcpServers, session/prompt, and session/update
mapped onto the shared emitter protocol. Keep headless streaming-json as
an explicit NETCATTY_GROK_RUNTIME=streaming-json fallback.
2026-08-02 15:44:28 +08:00
hang666 76ce594fbd feat(ai): lock down Grok local tools in MCP mode
In MCP tool-integration mode, pass --disallowed-tools for Grok built-in
shell/edit/write so remote session work goes through injected Netcatty MCP,
matching Claude-style policy. Skills mode leaves local shell available for
the Netcatty CLI skill path.
2026-08-02 15:41:41 +08:00
hang666 6404c6b209 feat(ai): add Grok model preset fallback
Curate GROK_MODEL_PRESETS and wire getAgentModelPresets so the chat model
picker still has a non-empty list when live grok models discovery fails,
matching the Claude/Codex fallback pattern.
2026-08-02 15:40:34 +08:00
bincxz c95bdbb131 feat(ai): add Catty context controls 2026-08-02 14:22:40 +08:00
netcatty-bot e8769cd590 fix(#2663): automated Cursor CLI fix 2026-08-02 06:14:24 +00:00
陈大猫 b76dfd76da Merge pull request #2657 from chenqin231/feat/context-menu-over-fullscreen-apps
feat(terminal): add setting to show context menu over fullscreen apps (tmux/vim)
2026-08-02 13:45:37 +08:00
陈大猫 70f4360d13 Merge pull request #2658 from chenqin231/feat/workspace-tab-host-name
feat(tabs): show host name on split-workspace tabs instead of "Workspace"
2026-08-02 13:32:34 +08:00
hang666 cd32635f44 test(ai): cover Grok Build coding CLI provider matching
Assert the grok executable basename and Windows path resolve to the
Grok Build coding-CLI provider for terminal tab icons and discovery UX.
2026-08-02 10:32:34 +08:00
hang666 c46247e996 feat(ai): wire Grok Build settings UI and i18n
Expose Grok as a managed Settings agent card, icon, coding-CLI provider,
and runtime model discovery target. Add en/zh/ru strings for in-app Grok
Build (distinct from External MCP client install).
2026-08-02 10:31:29 +08:00
hang666 a6a54e705f feat(ai): discover and match Grok Build as managed agent
Add grok to ManagedAgentKey/meta, discovery known-agents list, XAI_API_KEY
and ~/.grok/auth.json auth probe, and closed sdkBackend unions so the CLI
is discoverable and routable like other managed agents.
2026-08-02 10:28:51 +08:00
hang666 c456a97224 feat(ai): add Grok Build managed agent CLI driver
Register a headless streaming-json driver for backend key grok that maps
ACP-derived NDJSON events into the shared emitter protocol and injects
Netcatty MCP via a project .grok/config.toml merge with restore.
2026-08-02 10:25:23 +08:00
chin 889acd724d fix(tabs): preserve workspace autoTitle across session restore
Codex P2: restoreWorkspace() rebuilds the workspace object field-by-field
and omitted autoTitle, so with Restore Previous Session enabled an
explicitly named "Workspace" (rename / creation dialog / snippet) lost its
autoTitle:false on restore and got relabeled with a host name.

Carry autoTitle through restoreWorkspace with a boolean guard so a
deliberate false survives and legacy workspaces stay flagless. Adds restore
coverage for both the explicit-false and legacy-absent cases.
2026-08-01 19:17:31 +08:00
chin d136c52aef fix(tabs): flag explicitly-titled workspaces instead of a title-string sentinel
Codex P2: resolveWorkspaceTabLabel treated any workspace whose title equals
"Workspace" as an untouched default and replaced it with a host label — so a
workspace a user renamed to "Workspace" (or a snippet named "Workspace")
lost its chosen name.

Add an explicit Workspace.autoTitle flag. It is authoritative when present:
- set false on user rename, snippet-created, named "New Workspace", and any
  createWorkspaceFromTargets call given an explicit name;
- absent/true means the tab may derive a host label.
Legacy workspaces without the flag fall back to the old default-title string
check, so already-open default splits still get host labels and previously
renamed workspaces keep their names.

- domain/models/workspace.ts: add autoTitle field.
- domain/workspace.ts: thread autoTitle through createWorkspaceFromSessionIds.
- application/state/useSessionState.ts: mark rename / snippet / named
  creation paths as explicit; createWorkspaceFromTargets takes an optional
  name and only marks custom when one is given.
- domain/sessionTabTitle.ts: derive only when autoTitle (with legacy string
  fallback); tests for explicit "Workspace" name and explicit auto-title.
2026-08-01 19:11:26 +08:00
chin b3d250fc03 fix(tabs): count distinct workspace hosts by hostId, the only rename-stable id
Third Codex P2: submitSessionRename rewrites BOTH customName and hostLabel
(useSessionState.ts:585,597), so counting distinct hosts by hostLabel still
mislabeled a renamed same-host pane as a separate host and added a spurious
"+1".

Count by session.hostId instead — the only identity a rename leaves
untouched. All local terminals share the stable LOCAL_TERMINAL_HOST_ID and
remote sessions carry their host config id, so same-host panes collapse to
one regardless of renames. The focused pane still shows its rename as the
primary label. Tests updated to carry hostId (including a rename that
rewrites customName + hostLabel but keeps hostId).
2026-08-01 18:59:19 +08:00
chin 504344aa37 fix(tabs): count distinct workspace hosts by host label, not connection label
Follow-up Codex P2: the "+N" distinct-host set used getSessionConnectionLabel
(customName || hostLabel), so two panes on the same host where one session
was renamed produced different keys and a spurious "+1", presenting the
workspace as spanning multiple hosts.

Count distinct hosts by hostLabel alone. The focused pane still shows its
rename as the primary label; only the host count ignores per-session
renames. Adds coverage for the renamed-same-host and rename-with-multi-host
cases.
2026-08-01 18:47:06 +08:00
chin f24039d2a0 fix(tabs): base workspace tab label on stable host identity, not dynamic title
Review feedback (Codex P2 + Copilot): resolveWorkspaceTabLabel derived
its label and its distinct-host "+N" count from resolveSessionTabTitle,
which folds in the dynamic shell/agent title. Two problems:
- those live titles are published only to the session presentation store,
  not the plain session records passed to the tab, so the label went stale
  under dynamicTabTitleMode 'agent'/'all';
- per-pane dynamic titles differ on a single host, so the "+N" suffix
  fired even when every pane was on the same host.

Use getSessionConnectionLabel (rename / host label) — the stable host
identity — for both the primary label and the distinct-host set. This is
also what the tab is meant to convey (which host), and it needs no
presentation-store subscription. Drops the now-unused dynamicTabTitleMode
argument from resolveWorkspaceTabLabel.
2026-08-01 18:37:16 +08:00
chin f11661bea0 feat(tabs): show host name on split-workspace tabs instead of "Workspace"
A tab holding a split workspace showed the generic default title
"Workspace", giving no hint which host it belonged to. Derive the tab
label from the workspace's focused session when the workspace still
carries the default title: a local shell reads as its host label
(e.g. "Localhost"), an SSH session as its configured host name. If the
workspace spans more than one distinct host, the focused host is shown
with a "+N" suffix. A user-renamed workspace keeps its custom title.

- domain/sessionTabTitle.ts: add DEFAULT_WORKSPACE_TITLE sentinel and
  resolveWorkspaceTabLabel(), reusing resolveSessionTabTitle so labels
  match what single-pane tabs already show.
- domain/workspace.ts, application/state/useSessionState.ts: reference the
  shared DEFAULT_WORKSPACE_TITLE constant for the default title.
- components/top-tabs/TopTabItems.tsx: render the derived label (with a
  title tooltip) instead of workspace.title.
- domain/sessionTabTitle.test.ts: cover rename, single/multi-host, empty.
2026-08-01 18:24:05 +08:00
chin 427ef3ee70 fix(terminal): honor fullscreen-apps menu setting in capture-phase interceptor
Copilot review caught that the new showContextMenuOverFullscreenApps flag
only affected the TerminalContextMenu suppression helpers, but the
capture-phase contextmenu interceptor in useTerminalEffects
(shouldInterceptMouseTrackingContextMenu) still called preventDefault +
stopImmediatePropagation on every non-Shift right-click while mouse
tracking was active. That killed the contextmenu event before it could
reach Radix, so the menu never opened even with the setting on.

Route an unmodified right-click through the same path as Shift+right-click
when the setting is on and rightClickBehavior is 'context-menu':
- shouldInterceptMouseTrackingContextMenu returns false (let contextmenu
  reach Radix so the app menu opens)
- shouldStopShiftRightClickMouseTrackingMouseDown returns true (stop the
  button press from reaching the TUI, so tmux/vim don't also react)

The setting is menu-only: paste / select-word right-click behaviors are
unchanged. Adds predicate coverage in middleClickBehavior.test.ts.
2026-08-01 13:38:44 +08:00
chin 8d2fa19530 feat(terminal): add setting to show context menu over fullscreen apps
Add an opt-in "Show menu over fullscreen apps" terminal setting
(showContextMenuOverFullscreenApps, default off). When enabled, the
right-click context menu opens even while a fullscreen app (tmux, vim,
less) holds mouse tracking, instead of the click being passed through to
the app.

Threaded via a new forceMenuInAlternateScreen flag through the
shouldSuppressMouseTrackingContextMenu predicate (single source of truth)
and its callers. Default off keeps current behavior unchanged; Shift+
Right-Click still works as the escape hatch when off.

- domain/models/terminal.ts: new field + default false
- application/syncPayload.ts: include key in synced terminal settings
- TerminalContextMenu.tsx: forceMenuInAlternateScreen through predicates + prop
- TerminalView.tsx: wire setting to prop
- TerminalBehaviorSettings.tsx: toggle row
- i18n en/ru/zh-CN/zh-TW: label + description
- TerminalContextMenu.test.ts: coverage for the opt-out
2026-08-01 13:29:47 +08:00
陈大猫 3d3352d6c4 Merge pull request #2654 from binaricat/codex/harden-ci-cursor-automation
fix: harden CI and Cursor automation
2026-07-31 20:54:37 +08:00
bincxz 025eb06f1f fix: normalize runtime diagnostics 2026-07-31 20:29:03 +08:00
bincxz 6e8e38d00b fix: compare stable TAP diagnostics 2026-07-31 20:27:31 +08:00
bincxz 006809b84a fix: reject unterminated TAP diagnostics 2026-07-31 20:25:15 +08:00
bincxz 954b12cfd4 fix: recognize attached TAP diagnostics 2026-07-31 20:23:34 +08:00
bincxz b545cb8042 fix: compare all non-TAP test output 2026-07-31 20:19:56 +08:00
bincxz 7daff1ce4d fix: restore dependencies before verification 2026-07-31 20:12:31 +08:00
bincxz e1998cfcd2 fix: protect existing test sources 2026-07-31 19:34:21 +08:00
bincxz 33c6570e08 fix: recheck deleted simple follow-ups 2026-07-31 19:26:50 +08:00
bincxz c64c120710 fix: consume test identities once 2026-07-31 19:20:37 +08:00
bincxz 937999accc fix: retain failed test identities 2026-07-31 19:17:46 +08:00
bincxz 2709b138c4 fix: preserve numbered test identities 2026-07-31 19:12:45 +08:00
bincxz 4994f5a454 fix: preserve successful test coverage 2026-07-31 19:10:37 +08:00
bincxz 83f524524f fix: compare runner failures across full output 2026-07-31 19:05:00 +08:00
bincxz 5832b91051 fix: protect automation validation boundaries 2026-07-31 18:57:25 +08:00
bincxz 0d75e0703d fix: reject added test-runner failures 2026-07-31 18:50:09 +08:00
bincxz 0e1b42d578 fix: close final validation and drop-path gaps 2026-07-31 18:43:04 +08:00
bincxz 8e81c6a5c9 fix: resume clean PRs after simple follow-ups 2026-07-31 18:34:34 +08:00
bincxz bfb2793a71 fix: preserve stable port assertions 2026-07-31 18:26:55 +08:00
bincxz bdaf3c5ae5 fix: close remaining automation validation gaps 2026-07-31 18:24:58 +08:00
bincxz eb83f71218 fix: normalize assertion diagnostics 2026-07-31 18:17:03 +08:00
bincxz 179efb10a6 fix: compare multiline test failures 2026-07-31 18:15:32 +08:00
bincxz e7126a02a1 fix: stabilize baseline diagnostics 2026-07-31 18:14:06 +08:00
bincxz f3e9788386 fix: ignore moved test locations 2026-07-31 18:13:06 +08:00
bincxz b629abbc69 fix: tighten baseline failure comparison 2026-07-31 18:11:10 +08:00
bincxz bf57c2e489 fix: preserve terminal folder drop paths 2026-07-31 18:01:02 +08:00
bincxz 030f145bb3 fix: run ARM dependency setup with bash 2026-07-31 17:53:48 +08:00
bincxz 4fc643dde0 fix: revalidate queued automation state 2026-07-31 17:49:37 +08:00
bincxz 7e26869a43 fix: close Cursor automation review gaps 2026-07-31 17:45:53 +08:00
bincxz 092b010ec0 fix: harden CI and Cursor automation 2026-07-31 17:34:03 +08:00
陈大猫 d637b906e3 Merge pull request #2652 from myderr/codex/fix-sftp-mfa-regression-after-2555
fix(sftp): 恢复侧栏浏览时复用 SSH 会话
2026-07-31 16:48:12 +08:00
ydm 2b33a417eb fix(sftp): 恢复侧栏浏览时复用 SSH 会话 2026-07-31 16:38:01 +08:00
213 changed files with 18823 additions and 962 deletions

No files matched your search

+35 -11
View File
@@ -92,6 +92,12 @@ permissions:
pull-requests: read
env:
# npm retries only transient registry fetches; deterministic install or
# postinstall failures still fail immediately.
NPM_CONFIG_FETCH_RETRIES: "4"
NPM_CONFIG_FETCH_RETRY_FACTOR: "2"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "10000"
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
@@ -224,6 +230,8 @@ jobs:
~/Library/Caches/electron-builder
~/AppData/Local/electron-builder/Cache
key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
electron-${{ runner.os }}-${{ runner.arch }}-
- name: Install deps
run: npm ci
@@ -335,7 +343,10 @@ jobs:
&& (needs.resolve-et.result == 'success' || needs.resolve-et.result == 'skipped')
runs-on: ubuntu-latest
container:
image: almalinux:8
# The GitHub runner already retries container pulls three times. Use the
# official Quay mirror to avoid the Docker Hub endpoint that repeatedly
# timed out in package validation.
image: quay.io/almalinuxorg/almalinux:8
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
@@ -374,8 +385,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
dnf -y install epel-release
dnf -y install \
dnf -y --setopt=retries=4 --setopt=timeout=30 install epel-release
dnf -y --setopt=retries=4 --setopt=timeout=30 install \
curl ca-certificates \
gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ make \
python3.11 python3.11-pip \
@@ -408,7 +419,8 @@ jobs:
BSDTAR_DEB_OK=0
for url in "${BSDTAR_DEB_URLS[@]}"; do
echo "trying ${url}"
if curl -fsSL -o /tmp/libarchive-tools.deb "${url}"; then
if curl -fsSL --retry 4 --retry-connrefused --connect-timeout 20 --max-time 300 \
-o /tmp/libarchive-tools.deb "${url}"; then
BSDTAR_DEB_OK=1
break
fi
@@ -497,7 +509,8 @@ jobs:
rpmbuild --version
bsdtar --version | head -n 1
# Official Node 22 linux-x64 tarball (same major as other package jobs).
NODE_VERSION="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt \
NODE_VERSION="$(curl -fsSL --retry 4 --retry-connrefused --connect-timeout 20 --max-time 300 \
https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt \
| awk '/node-v[0-9.]+-linux-x64\.tar\.xz$/{print $2; exit}' \
| sed -E 's/^node-(v[0-9.]+)-linux-x64\.tar\.xz$/\1/')"
if [[ -z "${NODE_VERSION}" ]]; then
@@ -505,7 +518,8 @@ jobs:
exit 1
fi
echo "Installing Node ${NODE_VERSION} from nodejs.org"
curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \
curl -fsSL --retry 4 --retry-connrefused --connect-timeout 20 --max-time 300 \
"https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1
node -v
npm -v
@@ -525,6 +539,8 @@ jobs:
~/.cache/electron
~/.cache/electron-builder
key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
electron-${{ runner.os }}-${{ runner.arch }}-
- name: Install deps
run: |
@@ -649,14 +665,17 @@ jobs:
fi
- name: Install build dependencies
shell: bash
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
set -euo pipefail
apt-get -o Acquire::Retries=4 update
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
curl -fsSL --retry 4 --retry-all-errors --connect-timeout 20 --max-time 300 \
https://deb.nodesource.com/setup_22.x | bash -
apt-get -o Acquire::Retries=4 install -y nodejs
- name: Checkout
uses: actions/checkout@v7
@@ -668,6 +687,8 @@ jobs:
~/.cache/electron
~/.cache/electron-builder
key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
electron-${{ runner.os }}-${{ runner.arch }}-
- name: Install deps
run: npm ci
@@ -866,7 +887,10 @@ jobs:
- name: Propose Nix release metadata
shell: bash
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
# RELEASE_TOKEN remains the checkout credential for branch pushes.
# PR creation prefers the triage PAT, whose repository permissions
# include pull requests, and falls back to this job's scoped token.
GH_TOKEN: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
+567 -63
View File
@@ -10,7 +10,7 @@ on:
# Route all PR lifecycle events through the default-branch workflow. This avoids
# approval-blocked pull_request / pull_request_review runs from fork authors.
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened, ready_for_review, closed]
# Reaction-only clean (👍 on @codex request) does not emit review events;
# poll open automation PRs so the loop can observe and mark ready.
schedule:
@@ -273,6 +273,20 @@ jobs:
ownActors,
repository: `${context.repo.owner}/${context.repo.repo}`,
});
if (
sameRepo &&
context.payload.action === 'closed' &&
auto.shouldGatePullOnSourceIssueFollowups(pr, {
ownActors,
repository: `${context.repo.owner}/${context.repo.repo}`,
})
) {
return set('source_issue_cleanup', {
issue_number: String(auto.extractSourceIssueNumber(pr)),
pull_number: String(pr.number),
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
});
}
if (sameRepo) {
if (eligible) {
if (['opened', 'ready_for_review', 'reopened'].includes(context.payload.action)) {
@@ -318,6 +332,82 @@ jobs:
return set('skip', { reason: `unhandled ${event}` });
cleanup_source_issue:
name: Clean source issue state
needs: route
if: needs.route.outputs.kind == 'source_issue_cleanup'
runs-on: ubuntu-latest
concurrency:
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Clear stale workflow labels
uses: actions/github-script@v9
env:
PULL_NUMBER: ${{ needs.route.outputs.pull_number }}
SOURCE_ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
OWN_ACTORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
const pullNumber = Number(process.env.PULL_NUMBER);
const { data: pull } = await github.rest.pulls.get({
...context.repo,
pull_number: pullNumber,
});
if (pull.state !== 'closed') {
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
return;
}
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
ownActors: process.env.OWN_ACTORS,
repository: `${context.repo.owner}/${context.repo.repo}`,
})) {
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
return;
}
const issueNumber = auto.extractSourceIssueNumber(pull);
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
if (!issueNumber || issueNumber !== expectedIssueNumber) {
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
return;
}
const removeLabel = async (issue_number, name) => {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number,
name,
});
} catch (error) {
if (error.status !== 404) throw error;
}
};
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
}
for (const name of ['automation:codex-loop', 'ready-for-human']) {
await removeLabel(pullNumber, name);
}
classify:
name: Classify issue
needs: route
@@ -1124,11 +1214,14 @@ jobs:
if: needs.route.outputs.kind == 'issue_followup'
runs-on: ubuntu-latest
timeout-minutes: 120
concurrency:
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
issues: write
pull-requests: write
actions: read
actions: write
outputs:
action: ${{ steps.decision.outputs.action }}
should_publish: ${{ steps.patch.outputs.should_publish || 'false' }}
@@ -1159,11 +1252,13 @@ jobs:
set -euo pipefail
git fetch --depth=1 origin "$DEFAULT_BRANCH"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
git show "FETCH_HEAD:scripts/compare-ci-test-baseline.cjs" > "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
git show "FETCH_HEAD:scripts/prepare-cursor-research-input.sh" > "$RUNNER_TEMP/prepare-cursor-research-input.sh"
git show "FETCH_HEAD:.github/cursor/prompts/followup.md" > "$RUNNER_TEMP/followup.md"
git show "FETCH_HEAD:.github/cursor/prompts/research.md" > "$RUNNER_TEMP/research.md"
chmod 0555 "$RUNNER_TEMP/prepare-cursor-research-input.sh"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
test -s "$RUNNER_TEMP/prepare-cursor-research-input.sh"
test -s "$RUNNER_TEMP/followup.md"
test -s "$RUNNER_TEMP/research.md"
@@ -1253,6 +1348,78 @@ jobs:
pullNumber,
}),
});
- name: Handle simple resolution or acknowledgement
if: steps.prepare.outputs.simple_kind != ''
uses: actions/github-script@v9
env:
SIMPLE_KIND: ${{ steps.prepare.outputs.simple_kind }}
PENDING_IDS: ${{ steps.prepare.outputs.pending_ids }}
PENDING_SNAPSHOTS: ${{ steps.prepare.outputs.pending_snapshots }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const pullNumber = Number(process.env.PULL_NUMBER) || null;
const queuePullReadinessCheck = async () => {
if (!pullNumber) return;
await github.rest.actions.createWorkflowDispatch({
...context.repo,
workflow_id: 'cursor-automation.yml',
ref: context.payload.repository.default_branch,
inputs: { pull_number: String(pullNumber) },
});
core.info(`Queued a readiness check for PR #${pullNumber}.`);
};
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
});
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issueNumber, per_page: 100 },
);
const snapshots = JSON.parse(process.env.PENDING_SNAPSHOTS || '[]');
const changed = auto.getChangedIssueCommentSnapshotIds(comments, snapshots);
const pendingIds = process.env.PENDING_IDS.split(',').filter(Boolean);
const livePending = comments.filter((comment) =>
pendingIds.includes(String(comment.id)),
);
const liveKind = auto.classifySimpleIssueFollowup(livePending);
if (changed.length || !liveKind) {
if (!livePending.length) {
await queuePullReadinessCheck();
core.info('The simple follow-up was removed before reply; nothing to process.');
return;
}
const inputs = { issue_number: String(issueNumber) };
if (pullNumber) {
inputs.pull_number = String(pullNumber);
inputs.drain_backlog = 'true';
}
await github.rest.actions.createWorkflowDispatch({
...context.repo,
workflow_id: 'cursor-automation.yml',
ref: context.payload.repository.default_branch,
inputs,
});
core.info(`Follow-up changed before the simple reply; dispatched a fresh review for issue #${issueNumber}.`);
return;
}
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: auto.buildIssueFollowupReply({
commentIds: pendingIds,
result: 'no_change',
reply: auto.buildSimpleIssueFollowupReply(
issue,
liveKind,
),
pullNumber,
}),
});
await queuePullReadinessCheck();
- name: Pause linked pull request during follow-up review
if: steps.prepare.outputs.should_run == 'true' && steps.prepare.outputs.has_pull == 'true'
@@ -1296,6 +1463,24 @@ jobs:
if: steps.prepare.outputs.should_run == 'true' && steps.prepare.outputs.has_pull == 'true'
run: npm ci
- name: Install test shell dependencies
if: steps.prepare.outputs.should_run == 'true' && steps.prepare.outputs.has_pull == 'true'
run: |
# Match .github/workflows/test.yml — osc7Setup.test.ts requires fish in CI.
sudo apt-get update
sudo apt-get install -y fish
- name: Capture follow-up exact-base test baseline
if: steps.prepare.outputs.should_run == 'true' && steps.prepare.outputs.has_pull == 'true'
run: |
mkdir -p .cursor-runtime
set +e
npm test > .cursor-runtime/followup-base-tests.log 2>&1
status=$?
set -e
echo "$status" > .cursor-runtime/followup-base-tests.exit
tail -80 .cursor-runtime/followup-base-tests.log || true
- name: Install Cursor CLI
if: steps.prepare.outputs.should_run == 'true'
run: |
@@ -1581,32 +1766,77 @@ jobs:
}
' "$status" "$names" "$name_status"
- name: Install test shell dependencies
if: steps.decision.outputs.action == 'updated'
run: |
sudo apt-get update
sudo apt-get install -y fish
- name: Verify updated pull request
if: steps.decision.outputs.action == 'updated'
id: verify
env:
BASE_SHA: ${{ steps.snapshot.outputs.base_sha }}
run: |
mkdir -p .cursor-runtime
set -o pipefail
set +e
npm run lint
lint=$?
npm test
tests=$?
npm run build
build=$?
node -e '
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const base = JSON.parse(execFileSync("git", ["show", `${process.argv[1]}:package.json`], { encoding: "utf8" }));
const current = JSON.parse(fs.readFileSync("package.json", "utf8"));
const names = ["lint", "test", "build"];
const changed = names.filter((name) => base.scripts?.[name] !== current.scripts?.[name]);
if (changed.length) throw new Error(`Validation scripts changed: ${changed.join(", ")}`);
' "$BASE_SHA"
script_guard=$?
if [[ "$script_guard" == "0" ]]; then
npm ci 2>&1 | tee .cursor-runtime/followup-candidate-lint.log
deps=$?
else
deps=2
fi
if [[ "$script_guard" == "0" && "$deps" == "0" ]]; then
npm run lint 2>&1 | tee -a .cursor-runtime/followup-candidate-lint.log
lint=$?
npm test 2>&1 | tee .cursor-runtime/followup-candidate-tests.log
tests=$?
npm run build 2>&1 | tee .cursor-runtime/followup-candidate-build.log
build=$?
else
lint=2
tests=2
build=2
if [[ "$script_guard" != "0" ]]; then
reason="Skipped because package.json changed a validation script."
else
reason="Skipped because restoring locked dependencies failed."
fi
echo "$reason" | tee -a .cursor-runtime/followup-candidate-lint.log
echo "$reason" | tee .cursor-runtime/followup-candidate-tests.log .cursor-runtime/followup-candidate-build.log
fi
node "$RUNNER_TEMP/compare-ci-test-baseline.cjs" \
--baseline-log .cursor-runtime/followup-base-tests.log \
--baseline-exit "$(cat .cursor-runtime/followup-base-tests.exit)" \
--candidate-log .cursor-runtime/followup-candidate-tests.log \
--candidate-exit "$tests" \
--output .cursor-runtime/followup-test-comparison.json
tests_compared=$?
set -e
if [[ "$lint" != "0" || "$tests" != "0" || "$build" != "0" ]]; then
node -e '
const fs = require("node:fs");
const result = {
lint: Number(process.argv[1]),
tests: Number(process.argv[2]),
testComparison: Number(process.argv[3]),
build: Number(process.argv[4]),
scriptGuard: Number(process.argv[5]),
};
fs.writeFileSync(".cursor-runtime/followup-verify-result.json", JSON.stringify(result, null, 2) + "\n");
' "$lint" "$tests" "$tests_compared" "$build" "$script_guard"
if [[ "$script_guard" != "0" || "$lint" != "0" || "$tests_compared" != "0" || "$build" != "0" ]]; then
echo "passed=false" >> "$GITHUB_OUTPUT"
exit 1
exit 0
fi
echo "passed=true" >> "$GITHUB_OUTPUT"
- name: Prepare follow-up patch
if: steps.decision.outputs.action == 'updated' && steps.verify.outputs.passed == 'true'
if: steps.decision.outputs.action == 'updated'
id: patch
env:
BASE_SHA: ${{ steps.snapshot.outputs.base_sha }}
@@ -1640,10 +1870,11 @@ jobs:
test -s .cursor-runtime/followup.patch
printf '%s' "${{ steps.prepare.outputs.pending_ids }}" > .cursor-runtime/followup-pending-ids.txt
printf '%s' '${{ steps.prepare.outputs.pending_snapshots }}' > .cursor-runtime/followup-pending-snapshots.json
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "artifact_ready=true" >> "$GITHUB_OUTPUT"
echo "should_publish=${{ steps.verify.outputs.passed == 'true' }}" >> "$GITHUB_OUTPUT"
- name: Scan follow-up patch for secret leaks
if: steps.patch.outputs.should_publish == 'true'
if: steps.patch.outputs.artifact_ready == 'true'
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
run: |
@@ -1655,6 +1886,11 @@ jobs:
".cursor-runtime/followup-reply.out.md",
".cursor-runtime/followup-pending-ids.txt",
".cursor-runtime/followup-pending-snapshots.json",
".cursor-runtime/followup-verify-result.json",
".cursor-runtime/followup-test-comparison.json",
".cursor-runtime/followup-candidate-lint.log",
".cursor-runtime/followup-candidate-tests.log",
".cursor-runtime/followup-candidate-build.log",
],
process.env.CURSOR_API_KEY,
"issue-followup-publish",
@@ -1662,7 +1898,7 @@ jobs:
'
- name: Upload follow-up patch
if: steps.patch.outputs.should_publish == 'true'
if: steps.patch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
name: issue-followup-${{ github.run_id }}
@@ -1671,8 +1907,19 @@ jobs:
.cursor-runtime/followup-reply.out.md
.cursor-runtime/followup-pending-ids.txt
.cursor-runtime/followup-pending-snapshots.json
.cursor-runtime/followup-verify-result.json
.cursor-runtime/followup-test-comparison.json
.cursor-runtime/followup-candidate-lint.log
.cursor-runtime/followup-candidate-tests.log
.cursor-runtime/followup-candidate-build.log
if-no-files-found: error
- name: Fail after preserving rejected follow-up
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
run: |
echo "Follow-up patch was preserved, but candidate-specific verification failed." >&2
exit 1
- name: Finish follow-up without code changes
if: steps.decision.outputs.action == 'no_change' || steps.decision.outputs.action == 'blocked'
uses: actions/github-script@v9
@@ -1850,6 +2097,32 @@ jobs:
process.env.ISSUE_BOT_LOGINS,
);
const remainingIds = pendingIds.filter((id) => !processed.has(id));
if (pullNumber) {
const { data: livePull } = await github.rest.pulls.get({
...context.repo,
pull_number: pullNumber,
});
if (livePull.merged || livePull.merged_at) {
if (remainingIds.length) {
const chinese = /[\u3400-\u9fff]/u.test(`${issue.title || ''}\n${issue.body || ''}`);
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: auto.buildIssueFollowupReply({
commentIds: remainingIds,
result: 'blocked',
reply: chinese
? '相关修复已在本次处理期间合并,因此没有重新打开这条 Issue 或改回旧状态。如果合并后的版本仍有问题,请提交一条新的问题报告。'
: 'The related fix merged while this follow-up was being processed, so the issue was not reopened and its old state was not restored. If the merged version is still affected, please open a new report.',
pullNumber,
headSha: process.env.HEAD_SHA,
}),
});
}
core.info(`PR #${pullNumber} is merged; skipped stale follow-up handoff state changes.`);
return;
}
}
const labels = (issue.labels || []).map((label) =>
typeof label === 'string' ? label : label.name,
);
@@ -1898,6 +2171,17 @@ jobs:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const issueNumber = Number(process.env.ISSUE_NUMBER);
const pullNumber = Number(process.env.PULL_NUMBER) || null;
if (pullNumber) {
const { data: livePull } = await github.rest.pulls.get({
...context.repo,
pull_number: pullNumber,
});
if (livePull.merged || livePull.merged_at) {
core.info(`PR #${pullNumber} is merged; emergency handoff will not reopen the source issue.`);
return;
}
}
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
@@ -2525,7 +2809,9 @@ jobs:
test -s scripts/cursor-automation.cjs
- name: Freeze trusted helper
run: cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
run: |
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
- name: Skip if bot PR already open
id: existing
@@ -2573,6 +2859,24 @@ jobs:
if: steps.existing.outputs.exists != 'true'
run: npm ci
- name: Install test shell dependencies
if: steps.existing.outputs.exists != 'true'
run: |
# Match .github/workflows/test.yml — osc7Setup.test.ts requires fish in CI.
sudo apt-get update
sudo apt-get install -y fish
- name: Capture exact-base test baseline
if: steps.existing.outputs.exists != 'true'
run: |
mkdir -p .cursor-runtime
set +e
npm test > .cursor-runtime/base-tests.log 2>&1
status=$?
set -e
echo "$status" > .cursor-runtime/base-tests.exit
tail -80 .cursor-runtime/base-tests.log || true
- name: Install Cursor CLI
if: steps.existing.outputs.exists != 'true'
run: |
@@ -2676,6 +2980,7 @@ jobs:
- name: Check protected paths (tree + commits)
if: steps.existing.outputs.exists != 'true' && steps.changes.outputs.present == 'true'
id: check_protected
run: |
status="$(git status --porcelain --untracked-files=all -- . ':(exclude).cursor-runtime' ':(exclude).cursor-runtime/**')"
names="$(git diff --name-only "${{ steps.branch.outputs.base_sha }}" HEAD 2>/dev/null || true)"
@@ -2700,40 +3005,94 @@ jobs:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: Number(process.env.ISSUE_NUMBER),
});
await auto.markNeedsHuman({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
message: 'The automatic implementation pass did not produce a safe focused change. A maintainer needs to take a look.',
message: auto.buildImplementationFailureMessage(issue, {
kind: 'no_changes',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=no_changes -->`,
});
- name: Install test shell dependencies
if: steps.existing.outputs.exists != 'true' && steps.changes.outputs.present == 'true'
run: |
# Match .github/workflows/test.yml — osc7Setup.test.ts requires fish in CI.
sudo apt-get update
sudo apt-get install -y fish
- name: Verify
if: steps.existing.outputs.exists != 'true' && steps.changes.outputs.present == 'true'
id: verify
env:
BASE_SHA: ${{ steps.branch.outputs.base_sha }}
run: |
mkdir -p .cursor-runtime
set -o pipefail
set +e
npm run lint
lint=$?
npm test
tests=$?
npm run build
build=$?
node -e '
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const base = JSON.parse(execFileSync("git", ["show", `${process.argv[1]}:package.json`], { encoding: "utf8" }));
const current = JSON.parse(fs.readFileSync("package.json", "utf8"));
const names = ["lint", "test", "build"];
const changed = names.filter((name) => base.scripts?.[name] !== current.scripts?.[name]);
if (changed.length) throw new Error(`Validation scripts changed: ${changed.join(", ")}`);
' "$BASE_SHA"
script_guard=$?
if [[ "$script_guard" == "0" ]]; then
npm ci 2>&1 | tee .cursor-runtime/candidate-lint.log
deps=$?
else
deps=2
fi
if [[ "$script_guard" == "0" && "$deps" == "0" ]]; then
npm run lint 2>&1 | tee -a .cursor-runtime/candidate-lint.log
lint=$?
npm test 2>&1 | tee .cursor-runtime/candidate-tests.log
tests=$?
npm run build 2>&1 | tee .cursor-runtime/candidate-build.log
build=$?
else
lint=2
tests=2
build=2
if [[ "$script_guard" != "0" ]]; then
reason="Skipped because package.json changed a validation script."
else
reason="Skipped because restoring locked dependencies failed."
fi
echo "$reason" | tee -a .cursor-runtime/candidate-lint.log
echo "$reason" | tee .cursor-runtime/candidate-tests.log .cursor-runtime/candidate-build.log
fi
node "$RUNNER_TEMP/compare-ci-test-baseline.cjs" \
--baseline-log .cursor-runtime/base-tests.log \
--baseline-exit "$(cat .cursor-runtime/base-tests.exit)" \
--candidate-log .cursor-runtime/candidate-tests.log \
--candidate-exit "$tests" \
--output .cursor-runtime/test-comparison.json
tests_compared=$?
set -e
if [[ "$lint" != "0" || "$tests" != "0" || "$build" != "0" ]]; then
node -e '
const fs = require("node:fs");
const result = {
lint: Number(process.argv[1]),
tests: Number(process.argv[2]),
testComparison: Number(process.argv[3]),
build: Number(process.argv[4]),
scriptGuard: Number(process.argv[5]),
};
fs.writeFileSync(".cursor-runtime/verify-result.json", JSON.stringify(result, null, 2) + "\n");
' "$lint" "$tests" "$tests_compared" "$build" "$script_guard"
if [[ "$script_guard" != "0" || "$lint" != "0" || "$tests_compared" != "0" || "$build" != "0" ]]; then
echo "passed=false" >> "$GITHUB_OUTPUT"
exit 1
echo "kind=candidate_verification" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "passed=true" >> "$GITHUB_OUTPUT"
echo "kind=clean_or_baseline_only" >> "$GITHUB_OUTPUT"
- name: Prepare patch for isolated publish
if: steps.existing.outputs.exists != 'true' && steps.changes.outputs.present == 'true' && steps.verify.outputs.passed == 'true'
if: steps.existing.outputs.exists != 'true' && steps.changes.outputs.present == 'true'
id: patch
# No CURSOR_API_KEY here: agent may have installed hooks. Git runs with hooks disabled.
env:
@@ -2780,10 +3139,11 @@ jobs:
else
: > .cursor-runtime/implement-pr-body.out.md
fi
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "artifact_ready=true" >> "$GITHUB_OUTPUT"
echo "should_publish=${{ steps.verify.outputs.passed == 'true' }}" >> "$GITHUB_OUTPUT"
- name: Scan implement patch for secret leaks
if: steps.patch.outputs.should_publish == 'true'
if: steps.patch.outputs.artifact_ready == 'true'
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
run: |
@@ -2798,6 +3158,11 @@ jobs:
".cursor-runtime/implement-status.txt",
".cursor-runtime/implement-pr-body.out.md",
".cursor-runtime/implement-pr-body.md",
".cursor-runtime/verify-result.json",
".cursor-runtime/test-comparison.json",
".cursor-runtime/candidate-lint.log",
".cursor-runtime/candidate-tests.log",
".cursor-runtime/candidate-build.log",
],
process.env.CURSOR_API_KEY,
"implement-publish",
@@ -2805,7 +3170,7 @@ jobs:
'
- name: Upload implement patch
if: steps.patch.outputs.should_publish == 'true'
if: steps.patch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
name: implement-patch-${{ github.run_id }}
@@ -2813,8 +3178,19 @@ jobs:
.cursor-runtime/implement.patch
.cursor-runtime/implement-status.out.txt
.cursor-runtime/implement-pr-body.out.md
.cursor-runtime/verify-result.json
.cursor-runtime/test-comparison.json
.cursor-runtime/candidate-lint.log
.cursor-runtime/candidate-tests.log
.cursor-runtime/candidate-build.log
if-no-files-found: error
- name: Fail after preserving rejected implementation
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
run: |
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
exit 1
- name: Mark needs human on implement failure
if: failure() && steps.existing.outputs.exists != 'true'
uses: actions/github-script@v9
@@ -2826,12 +3202,29 @@ jobs:
? `${process.env.RUNNER_TEMP}/cursor-automation.cjs`
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
});
const protectedFailure = '${{ steps.check_protected.outcome }}' === 'failure';
const artifactReady = '${{ steps.patch.outputs.artifact_ready }}' === 'true';
const verifyFailed = '${{ steps.verify.outputs.passed }}' === 'false';
const kind = protectedFailure
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
: 'processing_failed';
await auto.markNeedsHuman({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
message:
'Automatic implementation did not finish cleanly (agent, protected paths, or verification failed). A maintainer needs to take a look.',
issueNumber,
message: auto.buildImplementationFailureMessage(issue, {
kind,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
}),
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
});
publish_implement:
@@ -3297,7 +3690,9 @@ jobs:
helper_supports_followups
- name: Freeze trusted helper
run: cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
run: |
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
- name: Inspect Codex outcome
id: codex
@@ -3608,8 +4003,14 @@ jobs:
GH_TOKEN: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
EXPECTED_HEAD: ${{ steps.codex.outputs.head_sha }}
PULL_NUMBER: ${{ needs.route.outputs.pull_number }}
OWN_ACTORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
run: |
set -euo pipefail
trusted_comment_authors="$(jq -Rn --arg raw "$OWN_ACTORS,github-actions[bot],github-actions" '$raw | split(",") | map(ascii_downcase) | unique')"
trusted_comment_bodies() {
gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PULL_NUMBER}/comments" 2>/dev/null \
| jq -r --argjson trusted "$trusted_comment_authors" '.[] | select((.user.login | ascii_downcase) as $login | $trusted | index($login)) | .body'
}
# Closed / missing PRs are not an automation failure.
state="$(gh pr view "$PULL_NUMBER" --repo "$GITHUB_REPOSITORY" --json state -q .state 2>/dev/null || echo MISSING)"
if [[ "$state" != "OPEN" ]]; then
@@ -3658,8 +4059,12 @@ jobs:
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PULL_NUMBER}/labels" \
-f 'labels[]=ready-for-human' >/dev/null 2>&1 || true
gh pr comment "$PULL_NUMBER" --repo "$GITHUB_REPOSITORY" --body \
"Codex reported no major issues, but the automation token cannot change draft state. A maintainer can mark this PR ready." 2>/dev/null || true
blocked_marker="<!-- cursor-codex-ready-blocked:${live_head} -->"
existing_comments="$(trusted_comment_bodies || true)"
if ! grep -Fq "$blocked_marker" <<<"$existing_comments"; then
blocked_body="$(printf '%s\n\n%s' "$blocked_marker" 'Codex reported no major issues, but the automation token cannot change draft state. A maintainer can mark this PR ready.')"
gh pr comment "$PULL_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$blocked_body" 2>/dev/null || true
fi
exit 0
fi
# Re-check head after gh pr ready (another push could race).
@@ -3694,8 +4099,15 @@ jobs:
echo "Failed to convert PR out of draft (token may lack draft permission); labels still applied." >&2
exit 0
fi
gh pr comment "$PULL_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$(cat <<'EOF'
clean_marker="<!-- cursor-codex-clean-head:${live_head} -->"
existing_comments="$(trusted_comment_bodies)"
if grep -Fq "$clean_marker" <<<"$existing_comments"; then
echo "Clean handoff already recorded for ${live_head}."
exit 0
fi
gh pr comment "$PULL_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$(cat <<EOF
<!-- cursor-automation -->
${clean_marker}
Codex reported no major issues. This PR is marked ready for human review/merge.
EOF
@@ -3836,6 +4248,13 @@ jobs:
if: steps.codex.outputs.action == 'fix'
run: npm ci
- name: Install test shell dependencies
if: steps.codex.outputs.action == 'fix'
run: |
# Match .github/workflows/test.yml — osc7Setup.test.ts requires fish in CI.
sudo apt-get update
sudo apt-get install -y fish
- name: Install Cursor CLI
if: steps.codex.outputs.action == 'fix'
run: |
@@ -3885,6 +4304,17 @@ jobs:
id: base
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Capture Codex-fix exact-base test baseline
if: steps.codex.outputs.action == 'fix'
run: |
mkdir -p .cursor-runtime
set +e
npm test > .cursor-runtime/fix-base-tests.log 2>&1
status=$?
set -e
echo "$status" > .cursor-runtime/fix-base-tests.exit
tail -80 .cursor-runtime/fix-base-tests.log || true
- name: Stage Cursor API key for fixes
if: steps.codex.outputs.action == 'fix'
env:
@@ -3976,20 +4406,74 @@ jobs:
}
' "$status" "$names" "$name_status"
- name: Install test shell dependencies
if: steps.codex.outputs.action == 'fix'
run: |
# Match .github/workflows/test.yml — osc7Setup.test.ts requires fish in CI.
sudo apt-get update
sudo apt-get install -y fish
- name: Verify fix
if: steps.codex.outputs.action == 'fix'
id: fixverify
env:
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
mkdir -p .cursor-runtime
set -o pipefail
set +e
npm run lint || exit 1
npm test || exit 1
npm run build || exit 1
node -e '
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const base = JSON.parse(execFileSync("git", ["show", `${process.argv[1]}:package.json`], { encoding: "utf8" }));
const current = JSON.parse(fs.readFileSync("package.json", "utf8"));
const names = ["lint", "test", "build"];
const changed = names.filter((name) => base.scripts?.[name] !== current.scripts?.[name]);
if (changed.length) throw new Error(`Validation scripts changed: ${changed.join(", ")}`);
' "$BASE_SHA"
script_guard=$?
if [[ "$script_guard" == "0" ]]; then
npm ci 2>&1 | tee .cursor-runtime/fix-candidate-lint.log
deps=$?
else
deps=2
fi
if [[ "$script_guard" == "0" && "$deps" == "0" ]]; then
npm run lint 2>&1 | tee -a .cursor-runtime/fix-candidate-lint.log
lint=$?
npm test 2>&1 | tee .cursor-runtime/fix-candidate-tests.log
tests=$?
npm run build 2>&1 | tee .cursor-runtime/fix-candidate-build.log
build=$?
else
lint=2
tests=2
build=2
if [[ "$script_guard" != "0" ]]; then
reason="Skipped because package.json changed a validation script."
else
reason="Skipped because restoring locked dependencies failed."
fi
echo "$reason" | tee -a .cursor-runtime/fix-candidate-lint.log
echo "$reason" | tee .cursor-runtime/fix-candidate-tests.log .cursor-runtime/fix-candidate-build.log
fi
node "$RUNNER_TEMP/compare-ci-test-baseline.cjs" \
--baseline-log .cursor-runtime/fix-base-tests.log \
--baseline-exit "$(cat .cursor-runtime/fix-base-tests.exit)" \
--candidate-log .cursor-runtime/fix-candidate-tests.log \
--candidate-exit "$tests" \
--output .cursor-runtime/fix-test-comparison.json
tests_compared=$?
set -e
node -e '
const fs = require("node:fs");
const result = {
lint: Number(process.argv[1]),
tests: Number(process.argv[2]),
testComparison: Number(process.argv[3]),
build: Number(process.argv[4]),
scriptGuard: Number(process.argv[5]),
};
fs.writeFileSync(".cursor-runtime/fix-verify-result.json", JSON.stringify(result, null, 2) + "\n");
' "$lint" "$tests" "$tests_compared" "$build" "$script_guard"
if [[ "$script_guard" != "0" || "$lint" != "0" || "$tests_compared" != "0" || "$build" != "0" ]]; then
echo "passed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "passed=true" >> "$GITHUB_OUTPUT"
- name: Prepare fix patch for isolated publish
if: steps.codex.outputs.action == 'fix'
@@ -4034,10 +4518,11 @@ jobs:
$GITH format-patch --stdout "${BASE_SHA}" > .cursor-runtime/fix.patch
test -s .cursor-runtime/fix.patch
echo "empty=false" >> "$GITHUB_OUTPUT"
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "artifact_ready=true" >> "$GITHUB_OUTPUT"
echo "should_publish=${{ steps.fixverify.outputs.passed == 'true' }}" >> "$GITHUB_OUTPUT"
- name: Scan fix patch for secret leaks
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.should_publish == 'true'
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
run: |
@@ -4045,20 +4530,39 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
auto.assertFilesDoNotContainSecret(
[".cursor-runtime/fix.patch"],
[
".cursor-runtime/fix.patch",
".cursor-runtime/fix-verify-result.json",
".cursor-runtime/fix-test-comparison.json",
".cursor-runtime/fix-candidate-lint.log",
".cursor-runtime/fix-candidate-tests.log",
".cursor-runtime/fix-candidate-build.log",
],
process.env.CURSOR_API_KEY,
"fix-publish",
);
'
- name: Upload fix patch
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.should_publish == 'true'
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
name: codex-fix-patch-${{ github.run_id }}
path: .cursor-runtime/fix.patch
path: |
.cursor-runtime/fix.patch
.cursor-runtime/fix-verify-result.json
.cursor-runtime/fix-test-comparison.json
.cursor-runtime/fix-candidate-lint.log
.cursor-runtime/fix-candidate-tests.log
.cursor-runtime/fix-candidate-build.log
if-no-files-found: error
- name: Fail after preserving rejected Codex fix
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
run: |
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
exit 1
- name: Mark needs human when no fix produced
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.empty == 'true'
uses: actions/github-script@v9
+8
View File
@@ -142,6 +142,14 @@ smoke coverage. Packaged-resource changes must also pass `npm run pack:dir`.
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
## Terminal Side Panel Splits
- `domain/sidePanelLayout.ts` owns the pure pane/split tree operations, including focus, unique tools, close collapse, resize, and the pane limit.
- `application/state/useTerminalSidePanelLayoutState.ts` owns per-terminal layouts and keeps the legacy focused-tool map compatible with external open paths.
- `TerminalLayerSidePanelSection.tsx` renders one shared toolbar plus the nested pane chrome. Each pane content host must remain `overflow-hidden` and layout-contained.
- Mounted tool panels use a stable portal node that moves between their pane host and the hidden parking host. Never fall back to an `absolute inset-0` side-panel root when a pane host is not ready.
- Closing the whole side panel clears that terminal's split tree. Switching terminal tabs must not reuse another tab's layout.
## Testing & Safety
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
- When changing storage keys or schema, provide migration or backward-compat handling.
+1 -1
View File
@@ -216,7 +216,7 @@ Windows リリースは未署名の場合があります。
> **Windows のポータブルデータ:** Netcatty を終了し、`Netcatty.exe`(zip 版)またはポータブル版ランチャーと同じ場所に `data` フォルダーを作成してください。次回起動時から、Netcatty はデータをこのフォルダーに保存します。保存済みのパスワードと秘密鍵は、作成した Windows ユーザーによって引き続き保護されます。別のコンピューターまたは Windows ユーザーへ移動した場合は、これらの機密情報を再入力する必要があります。
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。ZIP 版とポータブル版では、このメニューは追加されません。
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。**設定 → システム → Windows Explorer** からメニューの表示/非表示を切り替えられます。ZIP 版とポータブル版では、既定ではこのメニューは追加されません。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
+1 -1
View File
@@ -217,7 +217,7 @@ application and integration are complete.
> **Windows portable data:** Exit Netcatty, then create a folder named `data` beside `Netcatty.exe` (zip build) or beside the portable launcher. Netcatty will store its profile there on the next launch. Saved passwords and private keys remain protected by the Windows user account that created them, so they must be re-entered after moving the folder to another computer or Windows account.
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. ZIP and portable versions do not add this menu.
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. Turn the menu off or on in **Settings → System → Windows Explorer**. ZIP and portable versions do not add this menu by default.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
+1 -1
View File
@@ -215,7 +215,7 @@ Windows 发布文件可能仍未签名。
> **Windows 便携数据:** 退出 Netcatty,在 `Netcatty.exe`(zip 版)或便携版启动文件旁创建名为 `data` 的文件夹。下次启动后,Netcatty 会把数据保存在这里。已保存的密码和私钥仍受创建它们的 Windows 用户保护;将该文件夹移到其他电脑或 Windows 用户后,需要重新输入这些敏感信息。
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。ZIP 版和便携版不会添加此菜单。
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。可在 **设置 → 系统 → Windows 资源管理器** 中随时关闭或重新开启。ZIP 版和便携版默认不会添加此菜单。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
+27
View File
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
'ai.chat.activity.title': 'Agent activity',
'ai.chat.activity.plan': 'Plan',
'ai.chat.activity.webSearch': 'Web search',
@@ -257,6 +258,24 @@ export const enAiMessages: Messages = {
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
'ai.grok.detecting': 'Detecting...',
'ai.grok.detected': 'Detected',
'ai.grok.notFound': 'Not found',
'ai.grok.path': 'Path:',
'ai.grok.notFoundHint': 'Could not find grok in PATH. Install Grok Build CLI or specify the executable path below.',
'ai.grok.customPathPlaceholder': 'e.g. /usr/local/bin/grok',
'ai.grok.check': 'Check',
'ai.grok.resetPath': 'Reset',
'ai.grok.runtime.acp.title': 'Use Grok ACP (agent stdio)',
'ai.grok.runtime.acp.default': 'Default',
'ai.grok.runtime.acp.description':
'Talk to Grok over Agent Client Protocol (grok agent stdio). Injects Netcatty MCP on session/new. Turn off to use the original headless streaming-json CLI path.',
'ai.grok.runtime.streamingJson.hint':
'Using headless streaming-json (grok -p --output-format streaming-json). Project .grok/config.toml is used for MCP injection.',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -381,6 +400,7 @@ export const enAiMessages: Messages = {
'ai.chat.approveOnce': 'Once',
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
@@ -436,6 +456,7 @@ export const enAiMessages: Messages = {
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
'ai.chat.slashSystemCommands': 'Commands',
'ai.chat.slashQuickMessages': 'Quick messages',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Slash commands',
@@ -508,6 +529,11 @@ export const enAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.resizeSplit': 'Resize split',
'terminal.layer.splitHorizontal': 'Split top and bottom',
'terminal.layer.splitVertical': 'Split left and right',
'terminal.layer.openInNewSplit': 'Open in new split',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
@@ -544,6 +570,7 @@ export const enAiMessages: Messages = {
'topTabs.windowOpacity': 'Window opacity',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.resizeInput': 'Drag to resize the message input',
'ai.chat.attach': 'Attach',
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
'ai.chat.terminalSelectionLines': 'lines: {count}',
+3
View File
@@ -546,6 +546,9 @@ Highlight the focused split pane:
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
'settings.terminal.behavior.autoCloseOnExit': 'Auto-close terminal on exit',
'settings.terminal.behavior.autoCloseOnExit.desc':
'Allow terminal tabs and windows to close automatically after session exit. Turn this off to keep them open after every exit.',
+4
View File
@@ -680,6 +680,10 @@ export const enTerminalMessages: Messages = {
'snippets.selection.selectVisible': 'Select visible',
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.selection.deleteSelected': 'Delete ({count})',
'snippets.selection.deleteConfirmTitle': 'Delete selected items ({count})?',
'snippets.selection.deleteConfirmDesc': 'The selected items will be permanently deleted. This action cannot be undone.',
'snippets.selection.deleteSuccess': 'Deleted selected items: {count}.',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export script package',
'snippets.export.toast.empty': 'No snippets to export.',
+3
View File
@@ -487,6 +487,9 @@ export const enVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
'settings.jmsDeepLink.title': 'JumpServer Links',
'settings.jmsDeepLink.enable': 'Handle jms:// links',
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
+27
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
@@ -210,6 +211,24 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.elicitation.validation.format': 'Поле «{field}» должно соответствовать формату {format}.',
'ai.codebuddy.elicitation.validation.option': 'Выберите допустимый вариант для «{field}».',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'Агент программирования Grok Build от xAI (CLI). Установите Grok CLI, выполните `grok login` или задайте XAI_API_KEY, затем выберите его как внешнего агента.',
'ai.grok.detecting': 'Обнаружение...',
'ai.grok.detected': 'Обнаружен',
'ai.grok.notFound': 'Не найден',
'ai.grok.path': 'Путь:',
'ai.grok.notFoundHint': 'Не удалось найти grok в PATH. Установите Grok Build CLI или укажите путь к исполняемому файлу ниже.',
'ai.grok.customPathPlaceholder': 'например, /usr/local/bin/grok',
'ai.grok.check': 'Проверить',
'ai.grok.resetPath': 'Сбросить',
'ai.grok.runtime.acp.title': 'Использовать Grok ACP (agent stdio)',
'ai.grok.runtime.acp.default': 'По умолчанию',
'ai.grok.runtime.acp.description':
'Подключение к Grok через Agent Client Protocol (grok agent stdio). Netcatty MCP внедряется в session/new. Выключите, чтобы использовать исходный headless streaming-json CLI.',
'ai.grok.runtime.streamingJson.hint':
'Режим headless streaming-json (grok -p --output-format streaming-json). MCP внедряется через .grok/config.toml проекта.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
@@ -333,6 +352,7 @@ export const ruAiMessages: Messages = {
'ai.chat.approveOnce': 'Разрешить один раз',
'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
@@ -388,6 +408,7 @@ export const ruAiMessages: Messages = {
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
'ai.chat.slashSystemCommands': 'Системные команды',
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
'ai.chat.slashUserSkills': 'Пользовательские навыки',
'ai.chat.quickMessages': 'Команды /',
@@ -460,6 +481,11 @@ export const ruAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.closePane': 'Закрыть область',
'terminal.layer.resizeSplit': 'Изменить размер области',
'terminal.layer.splitHorizontal': 'Разделить сверху и снизу',
'terminal.layer.splitVertical': 'Разделить слева и справа',
'terminal.layer.openInNewSplit': 'Открыть в новой области',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
@@ -489,6 +515,7 @@ export const ruAiMessages: Messages = {
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',
'ai.chat.resizeInput': 'Перетащите, чтобы изменить высоту поля ввода',
'ai.chat.attach': 'Прикрепить',
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
'ai.chat.terminalSelectionLines': 'строк: {count}',
+3
View File
@@ -507,6 +507,9 @@ export const ruCoreMessages: Messages = {
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
'settings.terminal.behavior.autoCloseOnExit': 'Автоматически закрывать терминал при выходе',
'settings.terminal.behavior.autoCloseOnExit.desc':
'Разрешить автоматическое закрытие вкладок и окон терминала после завершения сеанса. Отключите, чтобы сохранять их после любого выхода.',
+4
View File
@@ -697,6 +697,10 @@ export const ruTerminalMessages: Messages = {
'snippets.selection.selectVisible': 'Выбрать видимые',
'snippets.selection.deselectAll': 'Снять выбор',
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
'snippets.selection.deleteSelected': 'Удалить ({count})',
'snippets.selection.deleteConfirmTitle': 'Удалить выбранные элементы ({count})?',
'snippets.selection.deleteConfirmDesc': 'Выбранные элементы будут удалены без возможности восстановления.',
'snippets.selection.deleteSuccess': 'Удалено выбранных элементов: {count}.',
'snippets.export.snippet': 'Экспорт сниппета',
'snippets.export.package': 'Экспорт пакета',
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
+3
View File
@@ -524,6 +524,9 @@ export const ruVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'snippets.selection.deleteSelected',
'snippets.selection.deleteConfirmTitle',
'snippets.selection.deleteConfirmDesc',
'snippets.selection.deleteSuccess',
] as const;
test('snippet bulk-delete copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
}
});
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
assert.equal(
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
'Delete selected items (1)?',
);
assert.equal(
en['snippets.selection.deleteSuccess'].replace('{count}', '1'),
'Deleted selected items: 1.',
);
});
+27
View File
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活动',
'ai.chat.activity.plan': '计划',
'ai.chat.activity.webSearch': '网页搜索',
@@ -257,6 +258,24 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
'ai.grok.detecting': '检测中...',
'ai.grok.detected': '已检测到',
'ai.grok.notFound': '未找到',
'ai.grok.path': '路径:',
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。请安装 Grok Build CLI 或在下方指定可执行文件路径。',
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
'ai.grok.check': '检查',
'ai.grok.resetPath': '重置',
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
'ai.grok.runtime.acp.default': '默认',
'ai.grok.runtime.acp.description':
'通过 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。关闭后使用原始 headless streaming-json CLI 路径。',
'ai.grok.runtime.streamingJson.hint':
'当前为 headless streaming-json(grok -p --output-format streaming-json)。MCP 通过项目 .grok/config.toml 注入。',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
@@ -381,6 +400,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.approveOnce': '允许',
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
@@ -436,6 +456,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
'ai.chat.slashSystemCommands': '系统命令',
'ai.chat.slashQuickMessages': '快捷消息',
'ai.chat.slashUserSkills': '用户 Skills',
'ai.chat.quickMessages': '快捷命令',
@@ -508,6 +529,11 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.resizeSplit': '调整分屏大小',
'terminal.layer.splitHorizontal': '上下分屏',
'terminal.layer.splitVertical': '左右分屏',
'terminal.layer.openInNewSplit': '在新分屏中打开',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
@@ -544,6 +570,7 @@ export const zhCNAiMessages: Messages = {
'topTabs.windowOpacity': '窗口透明度',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.resizeInput': '拖动调整消息输入框高度',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '终端选区',
'ai.chat.terminalSelectionLines': '{count} 行',
@@ -131,6 +131,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
'settings.jmsDeepLink.title': 'JumpServer 链接',
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
@@ -281,6 +284,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
'settings.terminal.behavior.autoCloseOnExit': '退出后自动关闭终端',
'settings.terminal.behavior.autoCloseOnExit.desc': '允许终端标签页和窗口在会话退出后自动关闭。关闭此项后,无论退出结果如何都会保留。',
'settings.terminal.behavior.rightClick.menu': '显示菜单',
@@ -758,6 +764,10 @@ export const zhCNTerminalMessages: Messages = {
'snippets.selection.selectVisible': '选择当前显示',
'snippets.selection.deselectAll': '取消选择',
'snippets.selection.exportSelected': '导出选中({count})',
'snippets.selection.deleteSelected': '删除({count})',
'snippets.selection.deleteConfirmTitle': '删除选中的 {count} 项?',
'snippets.selection.deleteConfirmDesc': '所选项目将被永久删除,此操作无法撤销。',
'snippets.selection.deleteSuccess': '已删除 {count} 个所选项目。',
'snippets.export.snippet': '导出代码片段',
'snippets.export.package': '导出脚本包',
'snippets.export.toast.empty': '没有可导出的代码片段。',
+27
View File
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活動',
'ai.chat.activity.plan': '計畫',
'ai.chat.activity.webSearch': '網頁搜尋',
@@ -257,6 +258,24 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重置',
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
'ai.grok.detecting': '偵測中...',
'ai.grok.detected': '已偵測到',
'ai.grok.notFound': '未找到',
'ai.grok.path': '路徑:',
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。請安裝 Grok Build CLI 或在下方指定執行檔路徑。',
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
'ai.grok.check': '檢查',
'ai.grok.resetPath': '重置',
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
'ai.grok.runtime.acp.default': '預設',
'ai.grok.runtime.acp.description':
'透過 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。關閉後使用原始 headless streaming-json CLI 路徑。',
'ai.grok.runtime.streamingJson.hint':
'目前為 headless streaming-json(grok -p --output-format streaming-json)。MCP 透過專案 .grok/config.toml 注入。',
// AI Default Agent
'ai.defaultAgent': '預設 Agent',
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
@@ -380,6 +399,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.approveOnce': '允許',
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
@@ -435,6 +455,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
'ai.chat.slashSystemCommands': '系統指令',
'ai.chat.slashQuickMessages': '快捷訊息',
'ai.chat.slashUserSkills': '使用者 Skills',
'ai.chat.quickMessages': '快捷指令',
@@ -507,6 +528,11 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.resizeSplit': '調整分割大小',
'terminal.layer.splitHorizontal': '上下分割',
'terminal.layer.splitVertical': '左右分割',
'terminal.layer.openInNewSplit': '在新分割中開啟',
'terminal.layer.hostTree.search': '搜尋主機...',
'terminal.layer.hostTree.searchButton': '搜尋',
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
@@ -543,6 +569,7 @@ export const zhTWAiMessages: Messages = {
'topTabs.windowOpacity': '視窗透明度',
'topTabs.openSettings': '開啟設定',
'ai.chat.sessionHistory': '工作階段歷史',
'ai.chat.resizeInput': '拖曳調整訊息輸入框高度',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '終端選區',
'ai.chat.terminalSelectionLines': '{count} 行',
@@ -131,6 +131,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
'settings.jmsDeepLink.title': 'JumpServer 連結',
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
@@ -281,6 +284,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右鍵行為',
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
'settings.terminal.behavior.autoCloseOnExit': '結束後自動關閉終端',
'settings.terminal.behavior.autoCloseOnExit.desc': '允許終端標籤頁和視窗在工作階段結束後自動關閉。關閉此項後,無論結束結果如何都會保留。',
'settings.terminal.behavior.rightClick.menu': '顯示選單',
@@ -758,6 +764,10 @@ export const zhTWTerminalMessages: Messages = {
'snippets.selection.selectVisible': '選擇目前顯示',
'snippets.selection.deselectAll': '取消選擇',
'snippets.selection.exportSelected': '匯出選取({count})',
'snippets.selection.deleteSelected': '刪除({count})',
'snippets.selection.deleteConfirmTitle': '刪除選取的 {count} 項?',
'snippets.selection.deleteConfirmDesc': '所選項目將被永久刪除,此操作無法復原。',
'snippets.selection.deleteSuccess': '已刪除 {count} 個所選項目。',
'snippets.export.snippet': '匯出程式碼片段',
'snippets.export.package': '匯出程式碼包',
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
+9 -2
View File
@@ -22,6 +22,7 @@ import {
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -72,7 +73,7 @@ interface UseSettingsIpcSyncParams {
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
setTerminalFontSize: (raw: unknown) => void;
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
@@ -82,6 +83,7 @@ interface UseSettingsIpcSyncParams {
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
@@ -125,6 +127,7 @@ export function useSettingsIpcSync({
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -193,7 +196,7 @@ export function useSettingsIpcSync({
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
if (key === STORAGE_KEY_TERM_FONT_SIZE) {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
@@ -235,6 +238,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setJmsDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && typeof value === 'boolean') {
setExplorerContextMenuEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -331,6 +337,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -95,6 +95,8 @@ export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
/** Windows Explorer context menu; true matches NSIS installer default. */
export const DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED = true;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
+16 -9
View File
@@ -16,6 +16,7 @@ import {
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
@@ -99,6 +100,7 @@ interface UseSettingsStorageSyncParams {
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
jmsDeepLinkEnabled: boolean;
explorerContextMenuEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -117,7 +119,7 @@ interface UseSettingsStorageSyncParams {
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
setTerminalFontSize: (raw: unknown) => void;
setSftpDoubleClickBehavior: Dispatch<SetStateAction<'open' | 'transfer'>>;
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
@@ -144,6 +146,7 @@ interface UseSettingsStorageSyncParams {
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
@@ -163,7 +166,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -172,7 +175,7 @@ export function useSettingsStorageSync({
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -187,7 +190,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
@@ -197,7 +200,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -283,10 +286,7 @@ export function useSettingsStorageSync({
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
setTerminalFontSize(e.newValue);
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
@@ -357,6 +357,12 @@ export function useSettingsStorageSync({
setJmsDeepLinkEnabledState(newValue);
}
}
if (e.key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.explorerContextMenuEnabled) {
setExplorerContextMenuEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -516,6 +522,7 @@ export function useSettingsStorageSync({
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -664,10 +664,13 @@ async function resumeSingleFileWithDedicatedSession(
: null;
if (!sourceStat) throw new Error("Source is unavailable");
{
// Remote download sources may grow append-only (live logs); keep the
// planned snapshot size and existing checkpoint instead of restarting.
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
});
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
const err = new Error(classified.message || validationError || "Source modified");
@@ -693,7 +696,9 @@ async function resumeSingleFileWithDedicatedSession(
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: task.totalBytes || undefined,
totalBytes: Number.isFinite(task.totalBytes)
? task.totalBytes
: undefined,
resumable: task.resumable !== false,
checkpointBytes: task.checkpointBytes ?? task.transferredBytes ?? 0,
resumeStage: task.resumeStage,
@@ -1032,6 +1037,9 @@ async function resumeDirectoryWithDedicatedSession(
const planned = traversal.files;
totalFiles = planned.length;
const hasCompactCheckpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint);
// Compact identities keep size+mtime so a rewrite/truncate fails closed.
// Append-only growth of an already-covered remote file invalidates the
// prefix and retransfers those entries — safer than path-only proofs.
const compactCheckpointValid = hasCompactCheckpoint
&& await validateDirectoryResumeCheckpoint(parent, planned);
if (
@@ -1110,6 +1118,15 @@ async function resumeDirectoryWithDedicatedSession(
const childId = persisted?.id ?? crypto.randomUUID();
const resetPersistedCheckpoint = resetReplaceStage
|| (hasCompactCheckpoint && !compactCheckpointValid);
// Prefer the original planned snapshot size for interrupted download
// children. Fresh traversal `file.size` may already include appends
// (live logs); re-planning to the grown size breaks growth-aware
// resume validation and restarts the file incorrectly.
const plannedTotalBytes = !resetPersistedCheckpoint
&& endpoints.isDownload
&& Number.isFinite(persisted?.totalBytes)
? Math.max(0, Number(persisted?.totalBytes) || 0)
: (file.size || persisted?.totalBytes || 0);
let childBase: TransferTask = {
...parent,
...persisted,
@@ -1123,7 +1140,7 @@ async function resumeDirectoryWithDedicatedSession(
progressMode: "bytes",
ownerId: "dedicated-resume",
status: "transferring",
totalBytes: file.size || persisted?.totalBytes || 0,
totalBytes: plannedTotalBytes,
transferredBytes: resetPersistedCheckpoint
? 0
: (persisted?.checkpointBytes ?? persisted?.transferredBytes ?? 0),
@@ -1168,10 +1185,11 @@ async function resumeDirectoryWithDedicatedSession(
if (!sourceStat) {
throw new Error("Source is unavailable");
}
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
});
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
childBase = {
@@ -1196,10 +1214,21 @@ async function resumeDirectoryWithDedicatedSession(
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
} else {
// Keep the planned snapshot size for growing remote downloads so
// we do not re-plan the whole file mid-resume. Preserve explicit
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
childBase = {
...childBase,
totalBytes: sourceStat.size || childBase.totalBytes,
sourceLastModified: sourceStat.lastModified ?? childBase.sourceLastModified,
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
@@ -1217,7 +1246,9 @@ async function resumeDirectoryWithDedicatedSession(
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: childBase.totalBytes || file.size || undefined,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
@@ -1235,7 +1266,9 @@ async function resumeDirectoryWithDedicatedSession(
options?.onChildUpdate?.({
...childBase,
status: "completed",
transferredBytes: childBase.totalBytes || file.size || childBase.transferredBytes,
transferredBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || childBase.transferredBytes),
speed: 0,
endTime: Date.now(),
error: undefined,
@@ -72,6 +72,31 @@ test("reconnects when the mapped session is missing", async () => {
assert.equal(sftpId, "sftp-reconnected");
});
test("resolves source session when restoring a missing browse session", async () => {
let connectOptions: { initialPath?: string; sourceSessionId?: string } | undefined;
let resolvedHost: Host | undefined;
const sessions = { current: new Map<string, string>() };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: host, right: null } },
resolveSourceSessionId: (hostId, reconnectHost) => {
resolvedHost = reconnectHost;
return hostId === "host-1" ? "term-1" : undefined;
},
connect: async (_side, _host, options) => {
connectOptions = options;
sessions.current.set("conn-1", "sftp-restored");
},
releaseConnection: noopReleaseConnection,
});
assert.equal(sftpId, "sftp-restored");
assert.equal(resolvedHost?.hostname, "ci.example");
assert.equal(connectOptions?.initialPath, "/root");
assert.equal(connectOptions?.sourceSessionId, "term-1");
});
test("forceReconnect reopens even when a mapping exists", async () => {
let connectCalls = 0;
const released: string[] = [];
@@ -11,8 +11,12 @@ export interface EnsureRemoteSftpSessionParams {
connect: (
side: "left" | "right",
host: Host | "local",
options?: { initialPath?: string; ignoreSharedCache?: boolean; tabId?: string },
options?: { initialPath?: string; ignoreSharedCache?: boolean; tabId?: string; sourceSessionId?: string },
) => Promise<void>;
/** Preferred already-authenticated SSH session for this reconnect. */
sourceSessionId?: string;
/** Resolve an SSH session after the reconnect host has been resolved. */
resolveSourceSessionId?: (hostId: string, host: Host) => string | undefined;
/**
* Per-tab connect-time host (includes session hostname/port/user overrides).
* Prefer this over the vault entry so upload reconnects keep the same endpoint.
@@ -47,6 +51,8 @@ export async function ensureRemoteSftpSession(
releaseConnection,
forceReconnect = false,
tabId,
sourceSessionId,
resolveSourceSessionId,
} = params;
const resolveHost = (): Host => {
@@ -115,10 +121,12 @@ export async function ensureRemoteSftpSession(
const paneBefore = getActivePane(side);
const resumePath = paneBefore?.connection?.currentPath;
const host = resolveHost();
const resolvedSourceSessionId = sourceSessionId ?? resolveSourceSessionId?.(host.id, host);
await connect(side, host, {
initialPath: resumePath,
ignoreSharedCache: true,
...(tabId ? { tabId } : {}),
...(resolvedSourceSessionId ? { sourceSessionId: resolvedSourceSessionId } : {}),
});
const sftpId = readMappedId();
+5
View File
@@ -106,6 +106,11 @@ export interface SftpStateOptions {
* When `host` is provided, only return a session whose live endpoint matches.
*/
resolveTransferSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* Resolve a live terminal session id for restoring parked browse sessions.
* This keeps side-panel tab switches on the already-authenticated SSH transport.
*/
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* @deprecated Transfer channels no longer park independently. SSH keep-alive
* is controlled by sshTransportIdleTtlMs in settings.
+6 -2
View File
@@ -568,6 +568,10 @@ export const useSftpConnections = ({
sharedHostCache?.path,
);
// Sudo never reuses a terminal shell; normalize before UI state so
// reusedConnection (spinner/overlay) matches the actual open path.
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
@@ -579,7 +583,7 @@ export const useSftpConnections = ({
// If the backend falls back to a fresh connection, the pane stays
// non-interactive (loading=true) with stale cached files visible —
// no worse than the previous UX of always showing a spinner.
reusedConnection: !!options?.sourceSessionId,
reusedConnection: !!sourceSessionId,
fileProtocol: host.sftpFileProtocol ?? 'auto',
};
@@ -652,7 +656,7 @@ export const useSftpConnections = ({
// immediately repeated after a shared-channel attempt fails.
const sftpId = await openSftpConnectionOnce({
bridge,
sourceSessionId: !host.sftpSudo ? options?.sourceSessionId : undefined,
sourceSessionId,
openOptions: {
sessionId: sftpSessionId,
...credentials,
+26 -3
View File
@@ -178,10 +178,12 @@ export const useSftpTransfers = ({
const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible;
const [transfers, setTransfersState] = useState<TransferTask[]>(() => sftpTransferCenterStore.getOwnerTasks(ownerId));
// Only re-surface live attention conflicts. Terminal rows may still carry a
// stale conflict payload after older Skip paths — those must not reopen the dialog.
const [conflicts, setConflicts] = useState<FileConflict[]>(() => sftpTransferCenterStore
.getOwnerTasks(ownerId)
.map((task) => task.conflict)
.filter((conflict): conflict is FileConflict => !!conflict));
.filter((task) => task.status === "attention" && !!task.conflict)
.map((task) => task.conflict as FileConflict));
// Track cancelled task IDs. has() also sees process-global cancel flags set by
// the transfer center after the React owner unmounts.
@@ -1393,8 +1395,20 @@ export const useSftpTransfers = ({
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) {
// Dialog can show a store-owned conflict before the panel adopts the row.
// Clearing only React state leaves attention+conflict in localStorage and
// the prompt returns on the next SFTP open (issue #2636).
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== conflictId);
setConflicts(conflictsRef.current);
if (action === "stop" || action === "skip") {
sftpTransferCenterStore.patchTask(conflictId, {
status: "cancelled",
conflict: undefined,
endTime: Date.now(),
speed: 0,
error: undefined,
});
}
return;
}
@@ -1436,9 +1450,18 @@ export const useSftpTransfers = ({
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
// Drop conflict so remount / restart cannot rehydrate the dialog from a
// cancelled row that still carries conflict metadata.
setTransfers((prev) =>
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
? {
...t,
status: "cancelled" as TransferStatus,
endTime: Date.now(),
conflict: undefined,
error: undefined,
speed: 0,
}
: t,
),
);
@@ -0,0 +1,365 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createLocalTerminalFontSizeRecord,
parseTerminalFontSizeRecord,
resolveAuthoritativeTerminalFontSizeStorage,
resolveIncomingTerminalFontSize,
resolveTerminalFontSizeStorage,
serializeTerminalFontSizeRecord,
shouldApplyTerminalFontSizeRecord,
shouldBroadcastTerminalFontSizeChange,
type TerminalFontSizeMutationSource,
type TerminalFontSizeRecord,
} from './terminalFontSizeSync.ts';
/**
* Minimal model of the settings ↔ main font-size sync loop that caused #2689.
* Models both IPC rebroadcast and stale localStorage overwrites.
*/
function simulateFontSizeClicks(options: {
shouldBroadcast: (
source: TerminalFontSizeMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource };
shouldApply: (current: TerminalFontSizeRecord, incoming: TerminalFontSizeRecord) => boolean;
versioned: boolean;
}): { settingsValues: number[]; mainValues: number[]; storageWrites: string[] } {
let settings: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
let main: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
let settingsSource: TerminalFontSizeMutationSource = 'local';
let mainSource: TerminalFontSizeMutationSource = 'local';
let storage = options.versioned
? serializeTerminalFontSizeRecord(settings)
: String(settings.fontSize);
const settingsValues: number[] = [];
const mainValues: number[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: TerminalFontSizeRecord }> = [];
const writeStorage = (record: TerminalFontSizeRecord) => {
const next = options.versioned
? serializeTerminalFontSizeRecord(record)
: String(record.fontSize);
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', fontSize: number) => {
const bump = (
prev: TerminalFontSizeRecord,
origin: string,
): TerminalFontSizeRecord => (
options.versioned
? { fontSize, version: prev.version + 1, origin }
: { fontSize, version: 0, origin: 'legacy' }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings, 'settings-window');
settingsValues.push(settings.fontSize);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main, 'main-window');
mainValues.push(main.fontSize);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: TerminalFontSizeRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.fontSize);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.fontSize);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = parseTerminalFontSizeRecord(storage);
applyIncoming(window, record);
};
// Match reporter steps: + (16→17) then - while a delayed peer echo of 17
// can still race with a later local 15.
applyLocal('settings', 17);
deliverStorageTo('main');
applyLocal('settings', 15);
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseTerminalFontSizeRecord accepts legacy plain numbers and versioned records', () => {
assert.deepEqual(
parseTerminalFontSizeRecord('16'),
{ fontSize: 16, version: 0, origin: 'legacy' },
);
assert.deepEqual(
parseTerminalFontSizeRecord(14),
{ fontSize: 14, version: 0, origin: 'legacy' },
);
assert.deepEqual(
parseTerminalFontSizeRecord({ fontSize: 18, version: 3, origin: 'window-a' }),
{ fontSize: 18, version: 3, origin: 'window-a' },
);
assert.deepEqual(
parseTerminalFontSizeRecord('{"fontSize":15,"version":9,"origin":"window-b"}'),
{ fontSize: 15, version: 9, origin: 'window-b' },
);
assert.deepEqual(
parseTerminalFontSizeRecord('17|12|settings-window'),
{ fontSize: 17, version: 12, origin: 'settings-window' },
);
assert.equal(parseTerminalFontSizeRecord('bad').fontSize, 14);
});
test('shouldApplyTerminalFontSizeRecord ignores stale revisions', () => {
const current = { fontSize: 15, version: 2, origin: 'window-a' };
assert.equal(
shouldApplyTerminalFontSizeRecord(
current,
{ fontSize: 17, version: 1, origin: 'window-b' },
),
false,
);
assert.equal(
shouldApplyTerminalFontSizeRecord(
current,
{ fontSize: 18, version: 3, origin: 'window-b' },
),
true,
);
assert.equal(shouldApplyTerminalFontSizeRecord(current, { ...current }), false);
});
test('simultaneous writers converge through storage in either persistence order', () => {
const run = (order: Array<'settings' | 'main'>) => {
const initial: TerminalFontSizeRecord = {
fontSize: 16,
version: 1,
origin: 'initial-window',
};
let storage = serializeTerminalFontSizeRecord(initial);
let settings = createLocalTerminalFontSizeRecord(
initial,
storage,
17,
'settings-window',
10,
);
let main = createLocalTerminalFontSizeRecord(
initial,
storage,
15,
'main-window',
10,
);
for (const writer of order) {
const current = writer === 'settings' ? settings : main;
const resolution = resolveTerminalFontSizeStorage(current, storage);
if (resolution.shouldAdopt) {
if (writer === 'settings') settings = resolution.record;
else main = resolution.record;
} else if (resolution.shouldPersist) {
storage = resolution.serializedRecord;
}
}
const settingsResolution = resolveTerminalFontSizeStorage(settings, storage);
const mainResolution = resolveTerminalFontSizeStorage(main, storage);
settings = settingsResolution.record;
main = mainResolution.record;
assert.equal(settingsResolution.shouldPersist, false);
assert.equal(mainResolution.shouldPersist, false);
assert.deepEqual(settings, main);
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldAdopt, false);
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldPersist, false);
return settings;
};
assert.deepEqual(run(['settings', 'main']), run(['main', 'settings']));
});
test('a stale window creates and persists a revision newer than shared storage', () => {
const stored = serializeTerminalFontSizeRecord({
fontSize: 18,
version: 7,
origin: 'main-window',
});
const local = createLocalTerminalFontSizeRecord(
{ fontSize: 15, version: 2, origin: 'settings-window' },
stored,
16,
'settings-window',
5,
);
assert.equal(local.version, 8);
const resolution = resolveTerminalFontSizeStorage(local, stored);
assert.equal(resolution.shouldAdopt, false);
assert.equal(resolution.shouldPersist, true);
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), local);
});
test('a rejected losing record repairs shared storage back to the current winner', () => {
const winner: TerminalFontSizeRecord = {
fontSize: 17,
version: 10,
origin: 'settings-window',
};
const loser: TerminalFontSizeRecord = {
fontSize: 15,
version: 10,
origin: 'main-window',
};
const loserRaw = serializeTerminalFontSizeRecord(loser);
const rejected = resolveIncomingTerminalFontSize(winner, loser, loserRaw);
assert.equal(rejected.shouldUpdate, false);
assert.deepEqual(rejected.record, winner);
assert.equal(
rejected.repairSerializedRecord,
serializeTerminalFontSizeRecord(winner),
);
const stable = resolveIncomingTerminalFontSize(
winner,
loser,
rejected.repairSerializedRecord,
);
assert.equal(stable.shouldUpdate, false);
assert.equal(stable.repairSerializedRecord, null);
});
test('a delayed persistence effect arbitrates from the latest authoritative record', () => {
const renderedWhenEffectWasScheduled: TerminalFontSizeRecord = {
fontSize: 14,
version: 9,
origin: 'old-window',
};
const winner: TerminalFontSizeRecord = {
fontSize: 17,
version: 10,
origin: 'settings-window',
};
const loser: TerminalFontSizeRecord = {
fontSize: 15,
version: 10,
origin: 'main-window',
};
const currentRef = { current: renderedWhenEffectWasScheduled };
currentRef.current = winner;
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
currentRef,
serializeTerminalFontSizeRecord(loser),
);
assert.equal(resolution.shouldAdopt, false);
assert.equal(resolution.shouldPersist, true);
assert.deepEqual(resolution.record, winner);
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), winner);
});
test('shouldBroadcastTerminalFontSizeChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast font size sync oscillates during rapid +/- clicks', () => {
const alwaysBroadcast = (
_source: TerminalFontSizeMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateFontSizeClicks({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has(17) && unique.has(15) && settingsValues.length > 2,
`expected oscillation between 17 and 15, got ${settingsValues.join(',')}`,
);
});
test('versioned font size sync ignores stale peer echoes during rapid +/- clicks', () => {
const { settingsValues, mainValues } = simulateFontSizeClicks({
shouldBroadcast: shouldBroadcastTerminalFontSizeChange,
shouldApply: shouldApplyTerminalFontSizeRecord,
versioned: true,
});
assert.deepEqual(settingsValues, [17, 15]);
assert.ok(mainValues.includes(15));
assert.equal(mainValues.includes(17) && mainValues[mainValues.length - 1] === 17, false);
});
test('serializeTerminalFontSizeRecord round-trips through parse', () => {
const record = { fontSize: 18, version: 9, origin: 'window|with delimiter' };
const raw = serializeTerminalFontSizeRecord(record);
assert.deepEqual(parseTerminalFontSizeRecord(raw), record);
});
test('serialized records remain readable by the old parseInt storage reader', () => {
const raw = serializeTerminalFontSizeRecord({
fontSize: 18,
version: 9,
origin: 'settings-window',
});
assert.equal(parseInt(raw, 10), 18);
});
+257
View File
@@ -0,0 +1,257 @@
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from '../../infrastructure/config/fonts';
export type TerminalFontSizeMutationSource = 'local' | 'incoming';
export type TerminalFontSizeRecord = {
fontSize: number;
version: number;
origin: string;
};
const LEGACY_TERMINAL_FONT_SIZE_ORIGIN = 'legacy';
function normalizeTerminalFontSizeOrigin(origin: unknown): string {
return typeof origin === 'string' && origin.length > 0
? origin
: LEGACY_TERMINAL_FONT_SIZE_ORIGIN;
}
export function clampTerminalFontSizeValue(fontSize: unknown): number {
const value = typeof fontSize === 'number' ? fontSize : Number(fontSize);
if (!Number.isFinite(value)) return DEFAULT_FONT_SIZE;
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(value)));
}
/**
* Parse persisted / IPC terminal-font-size payloads.
* Accepts legacy plain numbers ("16") and versioned records.
*/
export function parseTerminalFontSizeRecord(raw: unknown): TerminalFontSizeRecord {
if (typeof raw === 'number' || typeof raw === 'string') {
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
if (typeof trimmed === 'string' && trimmed.startsWith('{')) {
try {
return parseTerminalFontSizeRecord(JSON.parse(trimmed));
} catch {
// fall through to Number()
}
}
if (typeof trimmed === 'string') {
const [fontSizePart, versionPart, encodedOrigin, ...rest] = trimmed.split('|');
if (encodedOrigin && rest.length === 0) {
const fontSize = Number(fontSizePart);
const version = Number(versionPart);
if (Number.isFinite(fontSize) && Number.isFinite(version)) {
let origin = encodedOrigin;
try {
origin = decodeURIComponent(encodedOrigin);
} catch {
// Keep the encoded value as a stable tie-breaker.
}
return {
fontSize: clampTerminalFontSizeValue(fontSize),
version: version > 0 ? Math.floor(version) : 0,
origin: normalizeTerminalFontSizeOrigin(origin),
};
}
}
}
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber)) {
return {
fontSize: clampTerminalFontSizeValue(asNumber),
version: 0,
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
};
}
}
if (raw && typeof raw === 'object') {
const record = raw as { fontSize?: unknown; version?: unknown; origin?: unknown };
const fontSize = clampTerminalFontSizeValue(record.fontSize);
const version = Number(record.version);
return {
fontSize,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
origin: normalizeTerminalFontSizeOrigin(record.origin),
};
}
return {
fontSize: DEFAULT_FONT_SIZE,
version: 0,
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
};
}
export function serializeTerminalFontSizeRecord(record: TerminalFontSizeRecord): string {
const fontSize = clampTerminalFontSizeValue(record.fontSize);
const version = Math.max(0, Math.floor(record.version) || 0);
const origin = encodeURIComponent(normalizeTerminalFontSizeOrigin(record.origin));
// Keep the font size first so older Netcatty versions using parseInt() can
// still read the preference after a downgrade.
return `${fontSize}|${version}|${origin}`;
}
export function createTerminalFontSizeSyncOrigin(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function nextTerminalFontSizeSyncVersion(
currentVersion: number,
persistedVersion: number,
now: number = Date.now(),
): number {
return Math.max(
Math.floor(now),
Math.floor(currentVersion) + 1,
Math.floor(persistedVersion) + 1,
);
}
export function createLocalTerminalFontSizeRecord(
current: TerminalFontSizeRecord,
persistedRaw: unknown,
nextFontSizeValue: unknown,
origin: string,
now: number = Date.now(),
): TerminalFontSizeRecord {
const nextFontSize = clampTerminalFontSizeValue(nextFontSizeValue);
if (nextFontSize === current.fontSize) return current;
const persisted = parseTerminalFontSizeRecord(persistedRaw);
return {
fontSize: nextFontSize,
version: nextTerminalFontSizeSyncVersion(current.version, persisted.version, now),
origin: normalizeTerminalFontSizeOrigin(origin),
};
}
export function areTerminalFontSizeRecordsEqual(
left: TerminalFontSizeRecord,
right: TerminalFontSizeRecord,
): boolean {
return left.fontSize === right.fontSize
&& left.version === right.version
&& left.origin === right.origin;
}
/**
* Incoming peer updates must not clobber a newer local revision. A stable
* origin tie-break makes simultaneous writers converge on the same record.
*/
export function shouldApplyTerminalFontSizeRecord(
current: TerminalFontSizeRecord,
incoming: TerminalFontSizeRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
if (
incoming.version === 0
&& incoming.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
&& current.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
) {
return incoming.fontSize !== current.fontSize;
}
if (incoming.origin !== current.origin) return incoming.origin > current.origin;
return incoming.fontSize > current.fontSize;
}
export type TerminalFontSizeStorageResolution = {
record: TerminalFontSizeRecord;
serializedRecord: string;
shouldAdopt: boolean;
shouldPersist: boolean;
};
export type TerminalFontSizeIncomingResolution = {
record: TerminalFontSizeRecord;
shouldUpdate: boolean;
repairSerializedRecord: string | null;
};
export function resolveTerminalFontSizeStorage(
current: TerminalFontSizeRecord,
storedRaw: unknown,
): TerminalFontSizeStorageResolution {
const stored = parseTerminalFontSizeRecord(storedRaw);
if (shouldApplyTerminalFontSizeRecord(current, stored)) {
return {
record: stored,
serializedRecord: serializeTerminalFontSizeRecord(stored),
shouldAdopt: true,
shouldPersist: false,
};
}
const serializedRecord = serializeTerminalFontSizeRecord(current);
return {
record: current,
serializedRecord,
shouldAdopt: false,
shouldPersist: storedRaw !== serializedRecord,
};
}
export function resolveAuthoritativeTerminalFontSizeStorage(
currentRef: Readonly<{ current: TerminalFontSizeRecord }>,
storedRaw: unknown,
): TerminalFontSizeStorageResolution {
return resolveTerminalFontSizeStorage(currentRef.current, storedRaw);
}
export function resolveIncomingTerminalFontSize(
current: TerminalFontSizeRecord,
incomingRaw: unknown,
storedRaw: unknown,
): TerminalFontSizeIncomingResolution {
const incoming = parseTerminalFontSizeRecord(incomingRaw);
if (shouldApplyTerminalFontSizeRecord(current, incoming)) {
return {
record: incoming,
shouldUpdate: true,
repairSerializedRecord: null,
};
}
const storageResolution = resolveTerminalFontSizeStorage(current, storedRaw);
if (storageResolution.shouldAdopt) {
return {
record: storageResolution.record,
shouldUpdate: true,
repairSerializedRecord: null,
};
}
return {
record: current,
shouldUpdate: false,
repairSerializedRecord: storageResolution.shouldPersist
? storageResolution.serializedRecord
: null,
};
}
/**
* Decide whether a terminal-font-size state change should be rebroadcast to
* peer windows. Incoming IPC/storage updates must not notify again —
* otherwise +/- clicks in the Settings window ping-pong with the main window
* and terminals oscillate between the last two sizes (see #2689, same class
* as window-opacity #2018).
*/
export function shouldBroadcastTerminalFontSizeChange(
mutationSource: TerminalFontSizeMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
+26 -8
View File
@@ -13,15 +13,9 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId =
| 'sftp'
| 'scripts'
| 'history'
| 'theme'
| 'system'
| 'notes'
| 'ai';
export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
'sftp',
@@ -64,6 +58,30 @@ export function reorderTerminalSidePanelTab(
.order as TerminalSidePanelTabId[];
}
export function fitTerminalSidePanelTabs(params: {
shown: TerminalSidePanelTabId[];
collapsed: TerminalSidePanelTabId[];
active: TerminalSidePanelTabId | null;
maxShown: number;
}): { shown: TerminalSidePanelTabId[]; collapsed: TerminalSidePanelTabId[] } {
if (params.shown.length <= params.maxShown) {
return { shown: params.shown, collapsed: params.collapsed };
}
const kept = new Set(params.shown.slice(0, Math.max(1, params.maxShown)));
if (params.active && params.shown.includes(params.active) && !kept.has(params.active)) {
const lastKept = [...kept].at(-1);
if (lastKept) kept.delete(lastKept);
kept.add(params.active);
}
const shown = params.shown.filter((id) => kept.has(id));
const autoCollapsed = params.shown.filter((id) => !kept.has(id));
return {
shown,
collapsed: [...autoCollapsed, ...params.collapsed.filter((id) => !autoCollapsed.includes(id))],
};
}
/**
* Seed the modern layout storage from the legacy order-only key when needed.
*/
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampTerminalSidePanelWidth,
getTerminalSidePanelAvailableWidth,
getTerminalSidePanelMaxShownTools,
getTerminalSidePanelMaxWidth,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from './terminalSidePanelWidth.ts';
test('terminal side panel can expand to the wider maximum', () => {
assert.equal(getTerminalSidePanelMaxWidth(2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
assert.equal(clampTerminalSidePanelWidth(1400, 2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
});
test('terminal side panel collapses tool buttons before shared actions are clipped', () => {
assert.equal(getTerminalSidePanelMaxShownTools(280), 2);
assert.equal(getTerminalSidePanelMaxShownTools(320), 4);
assert.ok(getTerminalSidePanelMaxShownTools(420) >= 7);
});
test('terminal side panel uses the actual surface left after fixed sibling panels', () => {
assert.equal(getTerminalSidePanelAvailableWidth(780, 220), 560);
assert.equal(getTerminalSidePanelMaxWidth(560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
assert.equal(clampTerminalSidePanelWidth(680, 560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
test('terminal side panel keeps usable terminal space in smaller windows', () => {
assert.equal(getTerminalSidePanelMaxWidth(1000), 680);
assert.equal(clampTerminalSidePanelWidth(900, 1000), 680);
assert.equal(clampTerminalSidePanelWidth(100, 1000), TERMINAL_SIDE_PANEL_MIN_WIDTH);
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
@@ -0,0 +1,43 @@
export const TERMINAL_SIDE_PANEL_MIN_WIDTH = 280;
export const TERMINAL_SIDE_PANEL_MAX_WIDTH = 1200;
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH = 320;
export const TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH = 28;
export const TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH = 200;
export function getTerminalSidePanelAvailableWidth(
terminalLayerWidth: number,
occupiedSiblingWidth: number,
): number {
return Math.max(0, terminalLayerWidth - occupiedSiblingWidth);
}
export function getTerminalSidePanelMaxWidth(availableSurfaceWidth: number): number {
const availableWidth = availableSurfaceWidth - TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH;
return Math.max(
TERMINAL_SIDE_PANEL_MIN_WIDTH,
Math.min(TERMINAL_SIDE_PANEL_MAX_WIDTH, availableWidth),
);
}
export function clampTerminalSidePanelWidth(
width: number,
availableSurfaceWidth: number,
contentMinimumWidth = TERMINAL_SIDE_PANEL_MIN_WIDTH,
): number {
const maximumWidth = getTerminalSidePanelMaxWidth(availableSurfaceWidth);
const minimumWidth = Math.min(
maximumWidth,
Math.max(TERMINAL_SIDE_PANEL_MIN_WIDTH, contentMinimumWidth),
);
return Math.max(minimumWidth, Math.min(maximumWidth, width));
}
export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
return Math.max(
1,
Math.floor(
(panelWidth - TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH)
/ TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH,
),
);
}
+10 -12
View File
@@ -23,6 +23,7 @@ import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIDraft,
AISessionContextCompaction,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
@@ -49,8 +50,6 @@ import {
} from './aiDraftState';
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -765,15 +764,14 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
const clearSessionMessages = useCallback((sessionId: string) => {
getAgentRuntime().clearChatSession(sessionId);
void getAIBridge()?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
const persistContextCompaction = useCallback((
sessionId: string,
contextCompaction: AISessionContextCompaction,
) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
const next = prev.map(s => s.id === sessionId
? { ...s, contextCompaction, updatedAt: Date.now() }
: s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
@@ -1061,7 +1059,7 @@ export function useAIState() {
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
persistContextCompaction,
cleanupOrphanedSessions,
}), [
providers,
@@ -1118,7 +1116,7 @@ export function useAIState() {
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
persistContextCompaction,
cleanupOrphanedSessions,
]);
}
@@ -35,6 +35,7 @@ export const useApplicationBackend = () => {
const checkSshAgent = useCallback(async (options?: {
identityAgent?: string;
agentForwarding?: boolean;
hostname?: string;
port?: number;
username?: string;
+11 -3
View File
@@ -59,6 +59,7 @@ import type { SessionRestorePayload } from '../../domain/sessionRestore';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
export function addWorkspaceIfMissing(
@@ -625,7 +626,7 @@ export const useSessionState = ({
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
return null;
});
@@ -651,6 +652,7 @@ export const useSessionState = ({
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
autoTitle: false,
viewMode: 'split',
});
@@ -671,8 +673,12 @@ export const useSessionState = ({
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
| { kind: 'host'; host: Host };
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name?: string): string | null => {
if (targets.length === 0) return null;
// A caller-provided name is an explicit title; without one the workspace is
// auto-titled and the tab derives a host-based label.
const explicitName = name?.trim();
const workspaceTitle = explicitName || DEFAULT_WORKSPACE_TITLE;
const newSessions: TerminalSession[] = targets.map((target) => {
if (target.kind === 'local') {
@@ -694,7 +700,8 @@ export const useSessionState = ({
// count — matches the intent behind the QuickSwitcher "New
// Workspace" flow, which the user expects to land in focus view.
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
title: workspaceTitle,
autoTitle: explicitName ? false : undefined,
viewMode: 'focus',
});
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
@@ -1017,6 +1024,7 @@ export const useSessionState = ({
// Create a focus mode workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: snippet.label,
autoTitle: false,
viewMode: 'focus',
snippetId: snippet.id,
});
+185 -13
View File
@@ -48,6 +48,7 @@ import {
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_HTTP_NETWORK_PROXY,
@@ -112,6 +113,7 @@ import {
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
DEFAULT_JMS_DEEP_LINK_ENABLED,
DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
@@ -152,6 +154,16 @@ import {
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
import {
createLocalTerminalFontSizeRecord,
createTerminalFontSizeSyncOrigin,
parseTerminalFontSizeRecord,
resolveAuthoritativeTerminalFontSizeStorage,
resolveIncomingTerminalFontSize,
shouldBroadcastTerminalFontSizeChange,
type TerminalFontSizeMutationSource,
type TerminalFontSizeRecord,
} from './terminalFontSizeSync';
import {
hasPersistedAppearanceChanged,
resolveAppearanceSyncState,
@@ -218,7 +230,51 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? TERMINAL_FONT_AUTO;
});
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [terminalFontSizeRecord, setTerminalFontSizeRecord] = useState<TerminalFontSizeRecord>(() => {
const stored = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
if (stored === null) {
return { fontSize: DEFAULT_FONT_SIZE, version: 0, origin: 'legacy' };
}
return parseTerminalFontSizeRecord(stored);
});
const terminalFontSize = terminalFontSizeRecord.fontSize;
const terminalFontSizeRecordRef = useRef(terminalFontSizeRecord);
const terminalFontSizeMutationSourceRef = useRef<TerminalFontSizeMutationSource>('local');
const terminalFontSizeLocalOriginRef = useRef(createTerminalFontSizeSyncOrigin());
const setTerminalFontSize = useCallback((nextValue: SetStateAction<number>) => {
terminalFontSizeMutationSourceRef.current = 'local';
const persistedRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
const current = terminalFontSizeRecordRef.current;
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(current.fontSize)
: nextValue;
const next = createLocalTerminalFontSizeRecord(
current,
persistedRaw,
candidate,
terminalFontSizeLocalOriginRef.current,
);
if (next === current) return;
terminalFontSizeRecordRef.current = next;
setTerminalFontSizeRecord(next);
}, []);
const applyIncomingTerminalFontSize = useCallback((raw: unknown) => {
const resolution = resolveIncomingTerminalFontSize(
terminalFontSizeRecordRef.current,
raw,
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
if (resolution.repairSerializedRecord !== null) {
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
resolution.repairSerializedRecord,
);
}
if (!resolution.shouldUpdate) return;
terminalFontSizeMutationSourceRef.current = 'incoming';
terminalFontSizeRecordRef.current = resolution.record;
setTerminalFontSizeRecord(resolution.record);
}, []);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
@@ -379,6 +435,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
});
const [explorerContextMenuEnabled, setExplorerContextMenuEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
return stored ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED;
});
const [explorerContextMenuSupported, setExplorerContextMenuSupported] = useState<boolean>(() => {
if (typeof navigator === 'undefined') return false;
return /Win/i.test(navigator.platform) || /Windows/i.test(navigator.userAgent);
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -475,6 +539,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
const jmsDeepLinkSetRequestIdRef = useRef(0);
const explorerContextMenuMutationSourceRef = useRef<'local' | 'incoming'>('local');
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
const explorerContextMenuSetRequestIdRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
@@ -594,6 +661,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
});
}, []);
const applyIncomingExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
explorerContextMenuSetRequestIdRef.current += 1;
setExplorerContextMenuEnabledState((prev) => {
if (prev === enabled) return prev;
explorerContextMenuMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
if (!enableSettingsSync) return;
@@ -713,8 +789,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSize = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) applyIncomingTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
if (storedTermSettings) {
try {
@@ -744,6 +820,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
const storedExplorerContextMenuEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
applyIncomingExplorerContextMenuEnabled(storedExplorerContextMenuEnabled ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED);
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
@@ -804,7 +882,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [applyIncomingCustomKeyBindings, applyIncomingExplorerContextMenuEnabled, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, applyIncomingTerminalFontSize, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const appearanceRender: AppearanceRenderSnapshot = {
@@ -903,7 +981,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalThemeLightId,
setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalFontSize: applyIncomingTerminalFontSize,
mergeIncomingTerminalSettings,
setEditorWordWrapState,
setSessionLogsEnabled,
@@ -913,6 +991,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -961,16 +1040,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -1007,10 +1086,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
terminalFontSizeRecordRef,
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
// If another window already published a newer winner, adopt it instead of
// displaying and broadcasting a local value that peers will reject.
if (resolution.shouldAdopt) {
terminalFontSizeMutationSourceRef.current = 'incoming';
terminalFontSizeRecordRef.current = resolution.record;
setTerminalFontSizeRecord(resolution.record);
return;
}
if (resolution.shouldPersist) {
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
resolution.serializedRecord,
);
}
const decision = shouldBroadcastTerminalFontSizeChange(
terminalFontSizeMutationSourceRef.current,
persistMountedRef.current,
);
terminalFontSizeMutationSourceRef.current = decision.nextSource;
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, resolution.record);
}, [terminalFontSizeRecord, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
@@ -1346,6 +1447,74 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
useEffect(() => {
explorerContextMenuEnabledRef.current = explorerContextMenuEnabled;
}, [explorerContextMenuEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = explorerContextMenuSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getExplorerContextMenuEnabled) return;
void bridge.getExplorerContextMenuEnabled().then((result) => {
if (cancelled) return;
if (explorerContextMenuSetRequestIdRef.current !== requestIdAtStart) return;
const enabled = typeof result === 'object' && result
? result.enabled
: result;
const supported = typeof result === 'object' && result
? result.supported === true
: false;
setExplorerContextMenuSupported(supported);
if (typeof enabled !== 'boolean') return;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, enabled);
}).catch(() => {
// Keep the cached renderer value when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
const previous = explorerContextMenuEnabledRef.current;
const requestId = explorerContextMenuSetRequestIdRef.current + 1;
explorerContextMenuSetRequestIdRef.current = requestId;
explorerContextMenuMutationSourceRef.current = 'local';
setExplorerContextMenuEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setExplorerContextMenuEnabled) return;
void bridge.setExplorerContextMenuEnabled(enabled).then((result) => {
if (explorerContextMenuSetRequestIdRef.current !== requestId) return;
const success = typeof result === 'object' ? result.success : result;
if (success !== false) return;
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
? result.enabled
: previous;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, finalEnabled);
}).catch(() => {
if (explorerContextMenuSetRequestIdRef.current !== requestId) return;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
if (explorerContextMenuMutationSourceRef.current === 'incoming') {
explorerContextMenuMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
}, [explorerContextMenuEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
enabled: enableSystemEffects,
toggleWindowHotkey,
@@ -1545,6 +1714,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
explorerContextMenuEnabled,
setExplorerContextMenuEnabled,
explorerContextMenuSupported,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
@@ -1574,7 +1746,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
};
+3
View File
@@ -305,6 +305,7 @@ export const useSftpState = (
[hosts, identities, keys, transferKnownHosts, transferTerminalSettings],
);
const resolveTransferSourceSessionId = options?.resolveTransferSourceSessionId;
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
const openPoolSftpSession = useCallback(
async (host: Host) => {
@@ -709,6 +710,7 @@ export const useSftpState = (
connect,
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
@@ -740,6 +742,7 @@ export const useSftpState = (
getActivePane,
hosts,
lastConnectedHostRef,
resolveBrowseSourceSessionId,
sftpSessionsRef,
releaseConnection,
]);
@@ -0,0 +1,132 @@
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 { collectSidePanelPanes } from '../../domain/sidePanelLayout.ts';
import { useTerminalSidePanelLayoutState } from './useTerminalSidePanelLayoutState.ts';
test('terminal side panel layouts stay isolated and keep one pane per tool', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
state?.setSidePanelOpenTabs(new Map([
['terminal-a', 'notes'],
['terminal-b', 'scripts'],
]));
});
const layoutA = state?.sidePanelLayouts.get('terminal-a');
const layoutB = state?.sidePanelLayouts.get('terminal-b');
assert.ok(layoutA);
assert.ok(layoutB);
await act(async () => {
state?.splitPane('terminal-a', 'ai', 'vertical', {
paneId: 'pane-ai',
splitId: 'split-a',
}, 400);
});
assert.deepEqual(
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-a')!.root).map((pane) => pane.tool),
['notes', 'ai'],
);
assert.deepEqual(
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-b')!.root).map((pane) => pane.tool),
['scripts'],
);
await act(async () => {
state?.splitPane('terminal-a', 'notes', 'horizontal', {
paneId: 'unused-pane',
splitId: 'unused-split',
}, 400);
});
const focused = state!.sidePanelLayouts.get('terminal-a')!;
assert.equal(collectSidePanelPanes(focused.root).length, 2);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'notes');
// External open paths still write the focused-tool map. They must replace
// only the focused pane, then focus an existing pane without duplicating it.
await act(async () => {
state?.setSidePanelOpenTabs((current) => new Map(current).set('terminal-a', 'system'));
});
assert.deepEqual(
collectSidePanelPanes(state!.sidePanelLayouts.get('terminal-a')!.root).map((pane) => pane.tool),
['system', 'ai'],
);
await act(async () => {
state?.setSidePanelOpenTabs((current) => new Map(current).set('terminal-a', 'ai'));
});
const externallyFocused = state!.sidePanelLayouts.get('terminal-a')!;
assert.deepEqual(collectSidePanelPanes(externallyFocused.root).map((pane) => pane.tool), ['system', 'ai']);
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'ai');
assert.equal(
collectSidePanelPanes(externallyFocused.root).find((pane) => pane.id === externallyFocused.focusedPaneId)?.tool,
'ai',
);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('closing the final pane removes only that terminal side panel state', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
state?.setSidePanelOpenTabs(new Map([
['terminal-a', 'notes'],
['terminal-b', 'scripts'],
]));
});
const paneId = state!.sidePanelLayouts.get('terminal-a')!.focusedPaneId;
let closedLast = false;
await act(async () => {
closedLast = state!.closePane('terminal-a', paneId);
});
assert.equal(closedLast, true);
assert.equal(state!.sidePanelLayouts.has('terminal-a'), false);
assert.equal(state!.sidePanelOpenTabs.has('terminal-a'), false);
assert.equal(state!.sidePanelLayouts.has('terminal-b'), true);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
closeSidePanelPane,
createSidePanelLayout,
focusSidePanelPane,
getFocusedSidePanelPane,
resizeSidePanelSplit,
selectSidePanelTool,
splitSidePanelPane,
type SidePanelLayout,
type SidePanelSplitDirection,
type SidePanelTool,
} from '../../domain/sidePanelLayout';
export function useTerminalSidePanelLayoutState() {
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTool>>(new Map());
const [sidePanelLayouts, setSidePanelLayouts] = useState<Map<string, SidePanelLayout>>(new Map());
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
// public application boundary into the per-tab tree without disturbing the
// other panes in an existing layout.
useEffect(() => {
setSidePanelLayouts((previous) => {
let changed = false;
const next = new Map(previous);
for (const tabId of previous.keys()) {
if (!sidePanelOpenTabs.has(tabId)) {
next.delete(tabId);
changed = true;
}
}
for (const [tabId, tool] of sidePanelOpenTabs) {
const current = next.get(tabId);
const updated = current
? selectSidePanelTool(current, tool)
: createSidePanelLayout(tool, crypto.randomUUID());
if (updated !== current) {
next.set(tabId, updated);
changed = true;
}
}
if (changed) sidePanelLayoutsRef.current = next;
return changed ? next : previous;
});
}, [sidePanelOpenTabs]);
const commitLayout = useCallback((tabId: string, layout: SidePanelLayout) => {
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, layout);
const focusedTool = getFocusedSidePanelPane(layout).tool;
const openTabs = new Map(sidePanelOpenTabsRef.current).set(tabId, focusedTool);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
}, []);
const focusPane = useCallback((tabId: string, paneId: string) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = focusSidePanelPane(layout, paneId);
if (nextLayout !== layout) commitLayout(tabId, nextLayout);
}, [commitLayout]);
const splitPane = useCallback((
tabId: string,
tool: SidePanelTool,
direction: SidePanelSplitDirection,
ids: { paneId: string; splitId: string },
availableAxisLength: number,
) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
commitLayout(tabId, splitSidePanelPane(
layout,
layout.focusedPaneId,
tool,
direction,
ids,
availableAxisLength,
));
}, [commitLayout]);
const closePane = useCallback((tabId: string, paneId: string): boolean => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return false;
const nextLayout = closeSidePanelPane(layout, paneId);
if (nextLayout) {
commitLayout(tabId, nextLayout);
return false;
}
const layouts = new Map(sidePanelLayoutsRef.current);
const openTabs = new Map(sidePanelOpenTabsRef.current);
layouts.delete(tabId);
openTabs.delete(tabId);
sidePanelLayoutsRef.current = layouts;
sidePanelOpenTabsRef.current = openTabs;
setSidePanelLayouts(layouts);
setSidePanelOpenTabs(openTabs);
return true;
}, [commitLayout]);
const resizeSplit = useCallback((tabId: string, splitId: string, sizes: number[]) => {
const layout = sidePanelLayoutsRef.current.get(tabId);
if (!layout) return;
const nextLayout = resizeSidePanelSplit(layout, splitId, sizes);
if (nextLayout === layout) return;
const layouts = new Map(sidePanelLayoutsRef.current).set(tabId, nextLayout);
sidePanelLayoutsRef.current = layouts;
setSidePanelLayouts(layouts);
}, []);
return {
sidePanelOpenTabs,
setSidePanelOpenTabs,
sidePanelOpenTabsRef,
sidePanelLayouts,
sidePanelLayoutsRef,
focusPane,
splitPane,
closePane,
resizeSplit,
};
}
+37
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
type LocalStorageMock = {
clear(): void;
@@ -234,6 +235,42 @@ test("buildSyncPayload includes host tree sidebar visibility setting", () => {
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
});
test("buildSyncPayload reads the versioned terminal font size record", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
"18|9|settings-window",
);
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.terminalFontSize, 18);
});
test("applySyncPayload writes a newer terminal font size that old versions can read", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
"16|7|main-window",
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { terminalFontSize: 19 },
};
await applySyncPayload(payload, { importVaultData: () => {} });
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE);
assert.ok(raw);
assert.equal(parseInt(raw, 10), 19);
const record = parseTerminalFontSizeRecord(raw);
assert.equal(record.fontSize, 19);
assert.ok(record.version > 7);
assert.equal(record.origin, "sync-payload");
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
+24 -4
View File
@@ -37,6 +37,11 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
nextTerminalFontSizeSyncVersion,
parseTerminalFontSizeRecord,
serializeTerminalFontSizeRecord,
} from './state/terminalFontSizeSync';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -206,7 +211,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
@@ -400,8 +405,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
const termSizeRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
if (termSizeRaw != null && termSizeRaw !== '') {
settings.terminalFontSize = parseTerminalFontSizeRecord(termSizeRaw).fontSize;
}
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
@@ -596,7 +603,20 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
if (settings.terminalFontSize != null) {
const existing = parseTerminalFontSizeRecord(
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
serializeTerminalFontSizeRecord({
fontSize: settings.terminalFontSize,
// Bump so peer windows' version gates accept the synced value.
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
origin: 'sync-payload',
}),
);
}
if (settings.terminalSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
}
+5 -2
View File
@@ -1,13 +1,16 @@
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
; `--open-terminal-path="%1."` so paths with spaces stay one token and drive
; roots (`C:\`) do not break Windows quote parsing.
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "MUIVerb" "Open in Netcatty"
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --open-terminal-path "%1"'
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."'
; Open the directory currently displayed by Explorer.
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "MUIVerb" "Open in Netcatty"
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --open-terminal-path "%V"'
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."'
!macroend
!macro customUnInstall
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const panelSource = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
const selectorSource = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
const preparingSource = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const primaryToolbarSources = [
panelSource,
readFileSync(new URL('./ScriptsSidePanel.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('./HistorySidePanel.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('./SftpSidePanel.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('./notes/NotesManager.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('./systemManager/SystemManagerSidePanel.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('./terminal/ThemeSidePanel.tsx', import.meta.url), 'utf8'),
];
test('AI chat header matches the compact system panel toolbar height', () => {
assert.match(panelSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
assert.match(selectorSource, /className="group flex h-6/);
assert.match(selectorSource, /truncate text-\[11px\]/);
assert.match(panelSource, /className="h-6 w-6 rounded-md/);
});
test('AI chat preparing state keeps the same compact header height', () => {
assert.match(preparingSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
assert.match(preparingSource, /className="h-6 w-32/);
});
test('agent selector menu stays compact with restrained corners', () => {
assert.match(selectorSource, /w-\[256px\].*rounded-md/);
assert.match(selectorSource, /flex h-9 w-full items-center gap-2\.5 px-3/);
assert.doesNotMatch(selectorSource, /rounded-lg/);
assert.doesNotMatch(selectorSource, /rounded-2xl/);
});
test('terminal side panel tools share one primary toolbar height', () => {
for (const source of primaryToolbarSources) {
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
}
});
+20 -5
View File
@@ -11,6 +11,8 @@ import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
import { cn } from '../lib/utils';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
@@ -48,9 +50,12 @@ interface AIChatPanelContentProps {
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
canSteer: boolean;
@@ -108,9 +113,12 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
messages,
isStreaming,
activeCompaction = null,
contextUsage = null,
inputValue,
setInputValue,
handleSend,
handleCompact,
canCompact = false,
handleSteer,
handleStop,
canSteer,
@@ -157,7 +165,10 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
{/* ── Header ── */}
{!hideHeader && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'px-2 flex items-center justify-between border-b border-border/50',
)}>
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -172,16 +183,17 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
<ConversationExport
session={activeSession}
onExport={handleExport}
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground [&_svg]:size-3"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
<History size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
@@ -191,10 +203,10 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
className="h-6 w-6 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
<Plus size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
@@ -282,6 +294,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onCompact={handleCompact}
canCompact={canCompact}
onSteer={handleSteer}
onStop={handleStop}
isStreaming={isStreaming}
@@ -316,6 +330,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
contextUsage={contextUsage}
/>
</div>
</React.Profiler>
@@ -90,6 +90,7 @@ const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePan
addMessageToSession: () => undefined,
updateLastMessage: () => undefined,
updateMessageById: () => undefined,
persistContextCompaction: () => undefined,
providers: [],
activeProviderId: '',
activeModelId: '',
+94 -2
View File
@@ -60,6 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
import { useConversationExport } from './ai/hooks/useConversationExport';
import { useAgentContextUsage } from './ai/hooks/useAgentCompactionUi';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import {
buildCursorListModelsAgentEnv,
@@ -73,6 +74,7 @@ import {
type SdkRuntimeModelCatalog,
} from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import {
getAIPanelProfilerProps,
profileAIPanelCalculation,
@@ -229,8 +231,8 @@ const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing()
const { t } = useI18n();
return (
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
<div className="h-8 w-36 rounded-md bg-muted/45" />
<div className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 px-2 flex items-center`}>
<div className="h-6 w-32 rounded-md bg-muted/45" />
</div>
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
<div className="flex items-center gap-2">
@@ -262,6 +264,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
providers,
activeProviderId,
activeModelId,
@@ -325,6 +328,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
});
const setActiveSessionId = useCallback((id: string | null) => {
@@ -372,6 +376,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const isSteering = activeSessionId != null && steeringSessionId === activeSessionId;
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
const observedContextUsage = useAgentContextUsage(activeSessionId);
const inputValue = currentDraft?.text ?? '';
const files = currentDraft?.attachments ?? [];
const panelViewRef = useRef(normalizedPanelView);
@@ -635,6 +640,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentId, providers],
);
// Hide the ring until a real context_snapshot arrives. A synthetic 0% fallback
// misleads users when reopening long sessions (looks empty until the next turn).
const contextUsage = currentAgentId === 'catty' ? observedContextUsage : null;
// Catty-only manual compact. External agents own their own compaction.
// No minimum message count: short sessions may still run /compact (even if
// there is little to summarize).
const canCompact = currentAgentId === 'catty'
&& Boolean(activeSessionId)
&& !isStreaming
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
const handleAgentProviderModelSelect = useCallback(
(providerId: string, modelId: string) => {
setAgentProvider(currentAgentId, providerId);
@@ -1131,6 +1149,76 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
clearScopeDraft, showScopeSessionView, setActiveSessionId,
]);
const handleCompact = useCallback(async () => {
const session = activeSessionRef.current;
if (
currentAgentId !== 'catty'
|| !session
|| isStreaming
|| !effectiveActiveProvider
|| !effectiveActiveModelId.trim()
) {
return;
}
const controller = new AbortController();
abortControllersRef.current.set(session.id, controller);
setStreamingForScope(session.id, true);
try {
await sendToCattyAgent(
session.id,
scopeKey,
'',
controller,
session,
'',
{
activeProvider: effectiveActiveProvider,
activeModelId: effectiveActiveModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
commandTimeout,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope({
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
}),
autoTitleSession: () => {},
selectedUserSkillSlugs: [],
forceCompaction: true,
},
);
} finally {
setStreamingForScope(session.id, false);
if (abortControllersRef.current.get(session.id) === controller) {
abortControllersRef.current.delete(session.id);
}
}
}, [
abortControllersRef,
buildExecutorContextForScope,
commandBlocklist,
commandTimeout,
currentAgentId,
effectiveActiveModelId,
effectiveActiveProvider,
globalPermissionMode,
isStreaming,
scopeKey,
scopeLabel,
scopeTargetId,
scopeType,
sendToCattyAgent,
setStreamingForScope,
terminalSessions,
webSearchConfig,
]);
const handleSteer = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
const draft = currentDraftRef.current;
@@ -1376,9 +1464,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeCompaction={
activeCompaction?.sessionId === activeSessionId ? activeCompaction : null
}
contextUsage={contextUsage}
canCompact={canCompact}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
@@ -1440,6 +1531,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'addMessageToSession',
'updateLastMessage',
'updateMessageById',
'persistContextCompaction',
'providers',
'activeProviderId',
'activeModelId',
+4
View File
@@ -50,6 +50,10 @@ export interface AIChatSidePanelProps {
messageId: string,
updater: (msg: ChatMessage) => ChatMessage,
) => void;
persistContextCompaction: (
sessionId: string,
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
) => void;
// Provider config
providers: ProviderConfig[];
activeProviderId: string;
@@ -91,6 +91,7 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
assert.equal(shouldLoadSdkRuntimeModels(agent('cursor')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
+7 -2
View File
@@ -83,6 +83,7 @@ export function buildSdkRuntimeModelCacheKey(agent: {
acpCommand?: string;
env?: Record<string, string>;
codexRuntime?: 'sdk' | 'app-server';
grokRuntime?: 'acp' | 'streaming-json';
cursorAuthMode?: 'cli-login' | 'api-key';
}): string {
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
@@ -92,7 +93,10 @@ export function buildSdkRuntimeModelCacheKey(agent: {
const cursorAuth = sdkBackend === 'cursor'
? (agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key')
: '';
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', cursorAuth, ...envHints].join('\u0000');
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
@@ -189,7 +193,8 @@ export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
|| sdkBackend === 'codebuddy'
|| sdkBackend === 'opencode';
|| sdkBackend === 'opencode'
|| sdkBackend === 'grok';
}
export function shouldAdoptSdkCurrentModel(
+7 -3
View File
@@ -30,6 +30,7 @@ import {
type VariableSizeVirtualListHandle,
} from './ui/VariableSizeVirtualList';
import { Input } from './ui/input';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
export type HistoryPanelScope = 'host' | 'global';
@@ -241,7 +242,10 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
data-section="history-panel"
data-history-scope={scope}
>
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'px-2 border-b border-border/50 flex items-center gap-1.5',
)}>
<div className="relative flex-1 min-w-0">
<Search
size={12}
@@ -251,7 +255,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('history.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
className="h-6 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
{scope === 'host' && (
@@ -261,7 +265,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
disabled={!isSupportedSession || state.loading}
title={t('history.action.refresh')}
aria-label={t('history.action.refresh')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
</button>
@@ -29,3 +29,8 @@ test('editing enabled SSH agent controls persists the enabled state', () => {
test('enabling SSH agent login clears an imported none sentinel', () => {
assert.match(source, /resolveSshAgentToggleUpdate/);
});
test('login and forwarding warnings use independent agent status', () => {
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
});
+2 -1
View File
@@ -41,6 +41,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
effectiveFontSize,
hasEffectiveFontSizeOverride,
sshAgentStatus,
sshForwardingAgentStatus,
effectiveGroupDefaults,
effectiveAuthMethod,
showAlgorithmOverrides,
@@ -319,7 +320,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
{form.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
@@ -4,10 +4,14 @@ import test from 'node:test';
const source = readFileSync(new URL('./HostDetailsPanel.tsx', import.meta.url), 'utf8');
test('SSH agent availability ignores stale async responses', () => {
assert.match(source, /let cancelled = false/);
test('login and forwarding agent availability are checked independently', () => {
assert.equal(source.match(/let cancelled = false/g)?.length, 2);
assert.match(source, /if \(!cancelled\) setSshAgentStatus\(status\)/);
assert.match(source, /return \(\) => \{\s*cancelled = true/);
assert.match(source, /if \(!cancelled\) setSshForwardingAgentStatus\(status\)/);
assert.match(source, /if \(form\.agentForwarding \|\| form\.useSshAgent === true\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.useSshAgent === true \? form\.identityAgent : undefined,\s*hostname:/);
assert.match(source, /if \(form\.agentForwarding\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.identityAgent,\s*agentForwarding: true,/);
assert.equal(source.match(/return \(\) => \{\s*cancelled = true/g)?.length, 2);
assert.match(source, /sshForwardingAgentStatus=\{sshForwardingAgentStatus\}/);
});
test('inherited identity actions use the resolved authentication username', () => {
+24 -6
View File
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import {
getEffectiveHostDistro,
@@ -192,11 +192,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
const [sshAgentStatus, setSshAgentStatus] = useState<{
running: boolean;
startupType: string | null;
error: string | null;
} | null>(null);
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(null);
useEffect(() => {
let cancelled = false;
@@ -217,6 +214,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
};
}, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
useEffect(() => {
let cancelled = false;
if (form.agentForwarding) {
void checkSshAgent({
identityAgent: form.identityAgent,
agentForwarding: true,
hostname: form.hostname,
port: form.port,
username: form.username,
}).then((status) => {
if (!cancelled) setSshForwardingAgentStatus(status);
});
} else {
setSshForwardingAgentStatus(null);
}
return () => {
cancelled = true;
};
}, [form.agentForwarding, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
const initialHostId = initialData?.id;
@@ -1050,6 +1067,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
effectiveFontSize={effectiveFontSize}
hasEffectiveFontSizeOverride={hasEffectiveFontSizeOverride}
sshAgentStatus={sshAgentStatus}
sshForwardingAgentStatus={sshForwardingAgentStatus}
effectiveGroupDefaults={effectiveGroupDefaults}
effectiveAuthMethod={effectiveAuthMethod}
showAlgorithmOverrides={showAlgorithmOverrides}
+7 -3
View File
@@ -30,6 +30,7 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipContent';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const SCRIPT_ROW_HEIGHT = 34;
@@ -719,11 +720,14 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
data-section="snippets-panel"
>
{/* Sub view tabs */}
<div className="shrink-0 px-2 py-1 border-b border-border/50 flex items-center gap-1">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'px-2 border-b border-border/50 flex items-center gap-1',
)}>
<button
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
'flex-1 h-6 rounded-md text-[11px]',
subView === 'library' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('library')}
@@ -733,7 +737,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<button
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
'flex-1 h-6 rounded-md text-[11px]',
subView === 'running' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('running')}
+3
View File
@@ -570,6 +570,9 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
explorerContextMenuEnabled={settings.explorerContextMenuEnabled}
setExplorerContextMenuEnabled={settings.setExplorerContextMenuEnabled}
explorerContextMenuSupported={settings.explorerContextMenuSupported}
restorePreviousSession={settings.restorePreviousSession}
setRestorePreviousSession={settings.setRestorePreviousSession}
restoreTerminalCwd={settings.restoreTerminalCwd}
+24 -2
View File
@@ -12,6 +12,7 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
@@ -75,7 +76,12 @@ import {
recallSftpSidePanelPath,
rememberSftpSidePanelPath,
} from "./sftp/sftpSidePanelConnectionMemory";
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import {
listSftpConnectedHosts,
resolveSftpTransferSourceSessionId,
sftpHostEndpointsEqual,
sftpPickerSessionsEqual,
} from "../domain/sftpConnectedHosts";
import type { TerminalSession } from "../domain/models";
interface SftpSidePanelProps {
@@ -181,6 +187,20 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return resolveSftpTransferSourceSessionId(sessions, hostsById, hostId, host);
}, [hosts, sessions]);
// Browse restore can run before the session list reflects the focused tab;
// prefer the active source when its visible endpoint matches the pane host.
const resolveBrowseSourceSessionId = useCallback((hostId: string, host?: Host) => {
if (
activeSessionId
&& activeHost
&& activeHost.id === hostId
&& (!host || sftpHostEndpointsEqual(activeHost, host))
) {
return activeSessionId;
}
return resolveTransferSourceSessionId(hostId, host);
}, [activeHost, activeSessionId, resolveTransferSourceSessionId]);
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
@@ -221,6 +241,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
knownHosts,
onAddKnownHost,
resolveTransferSourceSessionId,
resolveBrowseSourceSessionId,
}), [
fileWatchHandlers,
hasOwnedEditorTab,
@@ -232,6 +253,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
knownHosts,
onAddKnownHost,
resolveTransferSourceSessionId,
resolveBrowseSourceSessionId,
]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
@@ -1551,7 +1573,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
>
{showWorkspaceHostHeader && displayHost && (
<div
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
data-section="terminal-sftp-host-header"
>
<div className="flex items-center gap-2 min-w-0">
+6
View File
@@ -54,3 +54,9 @@ test('script code editor restores Electron clipboard paste for Cmd/Ctrl+V and na
assert.match(codeEditorSource, /contextmenu:\s*false/);
assert.match(codeEditorSource, /editor\.action\.clipboardPasteAction/);
});
test('script code editor pastes into Monaco find widget instead of script body', () => {
assert.match(codeEditorSource, /pasteForMonacoEditorCommand/);
assert.match(codeEditorSource, /activeElement:\s*document\.activeElement/);
assert.match(codeEditorSource, /pasteIntoEditor:\s*\(\)\s*=>\s*handlePasteRef\.current\(\)/);
});
+48 -8
View File
@@ -21,6 +21,7 @@ import {
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
import { DEFAULT_SCRIPT_TEMPLATE, isScriptSnippet } from '../domain/snippetScript.ts';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { Button } from './ui/button';
@@ -510,11 +511,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{
type: 'snippet' | 'package';
id: string;
name: string;
} | null>(null);
const [deleteTarget, setDeleteTarget] = useState<
| { type: 'snippet' | 'package'; id: string; name: string }
| { type: 'selection'; ids: string[] }
| null
>(null);
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
@@ -1252,11 +1253,38 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
};
const requestDeleteSelectedSnippets = () => {
const ids = snippets
.filter((snippet) => selectedSnippetIds.has(snippet.id))
.map((snippet) => snippet.id);
if (ids.length === 0) return;
setDeleteTarget({ type: 'selection', ids });
};
const existingDeleteSelectionIds = deleteTarget?.type === 'selection'
? deleteTarget.ids.filter((id) => snippets.some((snippet) => snippet.id === id))
: [];
const confirmDeleteTarget = () => {
if (!deleteTarget) return;
if (deleteTarget.type === 'package') {
performDeletePackage(deleteTarget.id);
} else if (deleteTarget.type === 'selection') {
const deletedIds = new Set(deleteTarget.ids);
const result = deleteSelectedSnippetsFromVault(snippets, hosts, deletedIds);
if (result.deletedCount === 0) {
clearSnippetSelection();
setDeleteTarget(null);
return;
}
onBulkSave(result.snippets);
onUpdateHosts?.(result.hosts);
if (editingSnippet.id && deletedIds.has(editingSnippet.id)) {
handleClosePanel();
}
toast.success(t('snippets.selection.deleteSuccess', { count: result.deletedCount }));
clearSnippetSelection();
} else {
onDelete(deleteTarget.id);
setSelectedSnippetIds((prev) => {
@@ -1820,6 +1848,16 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Download size={12} className="mr-1" />
{t('snippets.selection.exportSelected', { count: selectedSnippetIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedSnippetIds.size === 0}
onClick={requestDeleteSelectedSnippets}
>
<Trash2 size={12} className="mr-1" />
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</Button>
<Button
variant="ghost"
size="icon"
@@ -2138,12 +2176,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t('vault.deleteConfirm.title', {
name: deleteTarget?.name ?? '',
})}
title={deleteTarget?.type === 'selection'
? t('snippets.selection.deleteConfirmTitle', { count: existingDeleteSelectionIds.length })
: t('vault.deleteConfirm.title', { name: deleteTarget?.name ?? '' })}
description={
deleteTarget?.type === 'package'
? t('vault.deleteConfirm.packageDesc')
: deleteTarget?.type === 'selection'
? t('snippets.selection.deleteConfirmDesc')
: t('vault.deleteConfirm.desc')
}
onOpenChange={(open) => {
+148 -85
View File
@@ -90,6 +90,7 @@ import {
listInvalidSftpPanelTabIds,
listTerminalTabIdsWithRetainingTransfers,
resolveSftpActiveTransfersCount,
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldScheduleSftpRetainedPanelCleanup,
@@ -106,6 +107,16 @@ import {
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
type SidePanelSplitDirection,
} from '../domain/sidePanelLayout';
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
import {
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from '../application/state/terminalSidePanelWidth';
import {
AIChatPanelsHost,
@@ -253,6 +264,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onRemoveSessionFromWorkspace,
}) => {
const { t } = useI18n();
// Side panel state must be initialized before any callbacks reference its
// setters or refs in their dependency arrays.
const {
sidePanelOpenTabs,
setSidePanelOpenTabs,
sidePanelOpenTabsRef,
sidePanelLayouts,
sidePanelLayoutsRef,
focusPane: focusSidePanelPaneForTab,
splitPane: splitSidePanelPaneForTab,
closePane: closeSidePanelPaneForTab,
resizeSplit: resizeSidePanelSplitForTab,
} = useTerminalSidePanelLayoutState();
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
@@ -429,7 +453,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, targetPanel);
return next;
});
}, [onUpdateSessionStatus]);
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
@@ -518,9 +542,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
@@ -528,16 +549,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [notesMountedTabIds, setNotesMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
STORAGE_KEY_SIDE_PANEL_WIDTH,
420,
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
// Remember the last sub-panel shown per tab so the toggle shortcut can
// restore it after a close. Overwritten on open, never cleared on close.
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
@@ -666,7 +686,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
}
}, [clearSftpPanelState, resolveTabActiveTransfersCount]);
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
const closeTerminalSidePanelTab = useCallback((tabId: string) => {
setSidePanelOpenTabs((previous) => {
const next = new Map(previous);
next.delete(tabId);
return next;
});
sftpOpeningTabIdsRef.current.delete(tabId);
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
} else {
clearSftpPanelState(tabId);
}
setAiMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setNotesOpenNoteByTab((previous) => {
const next = new Map(previous);
next.delete(tabId);
return next;
});
notesReturnTabRef.current.delete(tabId);
}, [clearSftpPanelState, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
const handleToggleWorkspaceComposeBar = useCallback(() => {
setIsComposeBarOpen(prev => !prev);
@@ -702,45 +749,38 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
&& currentHost.sftpSudo === host.sftpSudo
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
const keepSftpMounted = isClosing
&& shouldKeepSftpMountedAfterClose(activeTransfersCount);
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
const paneCount = currentLayout
? collectSidePanelPanes(currentLayout.root).length
: 1;
const isClosing = shouldCloseSftpSidePanel({
shouldKeepOpen,
isOpen,
isSameEndpoint: !!isSameEndpoint,
paneCount,
});
if (isClosing) {
sftpOpeningTabIdsRef.current.delete(tabId);
closeTerminalSidePanelTab(tabId);
return;
}
if (keepSftpMounted) {
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
} else if (!isClosing) {
sftpOpeningTabIdsRef.current.add(tabId);
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
if (cleanupTimer !== undefined) {
window.clearTimeout(cleanupTimer);
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpOpeningTabIdsRef.current.add(tabId);
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
if (cleanupTimer !== undefined) {
window.clearTimeout(cleanupTimer);
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, 'sftp');
}
next.set(tabId, 'sftp');
return next;
});
// Store or remove the host for this tab.
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
setSftpHostForTab(prev => {
const next = new Map(prev);
if (isClosing && !keepSftpMounted) {
next.delete(tabId);
} else {
next.set(tabId, host);
}
next.set(tabId, host);
return next;
});
@@ -764,8 +804,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
if (isClosing || !pendingUploadEntries?.length) {
// Clear any stale pending upload on close or when opening without new files
if (!pendingUploadEntries?.length) {
// Clear any stale pending upload when opening without new files.
next.delete(tabId);
} else {
next.set(tabId, {
@@ -778,7 +818,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
return next;
});
}, [resolveSftpOpenTarget, resolveTabActiveTransfersCount]);
}, [closeTerminalSidePanelTab, resolveSftpOpenTarget, setSidePanelOpenTabs, sidePanelLayoutsRef, sidePanelOpenTabsRef]);
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
setSftpPendingUploadsForTab(prev => {
@@ -938,7 +978,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return;
const sessionHost = sessionHostsMapRef.current.get(sessionId);
const visibleSftpHost = tabId && sidePanelOpenTabsRef.current.get(tabId) === 'sftp'
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
? sftpHostForTabRef.current.get(tabId) ?? null
: null;
if (!shouldProbeCommandCwd({
@@ -978,7 +1018,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
},
});
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
}, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, terminalBackend]);
}, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, sidePanelLayoutsRef, terminalBackend]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
@@ -1196,33 +1236,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (!activeTabId) return;
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
sftpOpeningTabIdsRef.current.delete(activeTabId);
const activeTransfersCount = resolveTabActiveTransfersCount(activeTabId);
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
sftpRetainedAfterCloseTabIdsRef.current.add(activeTabId);
// Seed the ref so subsequent zero reports cannot drop a live store owner.
sftpActiveTransfersByTabRef.current.set(activeTabId, activeTransfersCount);
} else {
clearSftpPanelState(activeTabId);
}
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setNotesMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setNotesOpenNoteByTab((prev) => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
notesReturnTabRef.current.delete(activeTabId);
closeTerminalSidePanelTab(activeTabId);
refocusTerminalSession(sessionIdToRefocus);
}, [clearSftpPanelState, getActiveTerminalSessionId, refocusTerminalSession, resolveTabActiveTransfersCount, syncWorkspaceFocusIfNeeded]);
}, [closeTerminalSidePanelTab, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
// host of the workspace's focused session or the active session. null = none.
@@ -1242,15 +1258,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return null;
}, []);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
// If already on this tab, do nothing — user must click X to close
if (currentPanel === tab) return;
const prepareSidePanelTool = useCallback((tabId: string, tab: SidePanelTab): boolean => {
if (tab === 'sftp') {
sftpOpeningTabIdsRef.current.add(tabId);
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
@@ -1290,8 +1298,21 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Keep the hidden SFTP panel mounted while switching sub-panels. A panel
// closed during a transfer is also retained until the user reopens it and
// explicitly closes the now-idle panel, preserving the completed history.
markSidePanelSubTabOpened(tabId, tab);
return true;
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
// Switch only the focused pane. If the tool is already open elsewhere in
// the tree, the domain helper focuses that pane instead of duplicating it.
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
// If already on this tab, do nothing — user must click X to close
if (currentPanel === tab) return;
if (!prepareSidePanelTool(tabId, tab)) return;
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
@@ -1299,7 +1320,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return next;
});
});
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
}, [prepareSidePanelTool, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
const handleFocusSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
focusSidePanelPaneForTab(tabId, paneId);
}, [focusSidePanelPaneForTab]);
const handleSplitSidePanelPane = useCallback((
tool: SidePanelTab,
direction: SidePanelSplitDirection,
availableAxisLength: number,
) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
if (!sidePanelLayoutsRef.current.has(tabId) || !prepareSidePanelTool(tabId, tool)) return;
splitSidePanelPaneForTab(
tabId,
tool,
direction,
{ paneId: crypto.randomUUID(), splitId: crypto.randomUUID() },
availableAxisLength,
);
}, [prepareSidePanelTool, sidePanelLayoutsRef, splitSidePanelPaneForTab]);
const handleCloseSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
if (closeSidePanelPaneForTab(tabId, paneId)) {
handleCloseSidePanel();
}
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
resizeSidePanelSplitForTab(tabId, splitId, sizes);
}, [resizeSidePanelSplitForTab]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
@@ -1332,7 +1390,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return next;
});
});
}, [handleCloseSidePanel, markSidePanelSubTabOpened]);
}, [handleCloseSidePanel, markSidePanelSubTabOpened, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
@@ -1352,7 +1410,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// If the remembered panel is SFTP but no host is resolvable, use scripts.
const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab;
handleSwitchSidePanelTab(target);
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab]);
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab, sidePanelOpenTabsRef]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
@@ -1381,7 +1439,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
notesReturnTabRef.current.set(tabId, currentPanel);
}
handleSwitchSidePanelTab('notes');
}, [handleSwitchSidePanelTab]);
}, [handleSwitchSidePanelTab, sidePanelOpenTabsRef]);
const handleBackFromNotes = useCallback(() => {
const tabId = activeTabIdRef.current;
@@ -1408,7 +1466,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, 'notes');
return next;
});
}, []);
}, [setSidePanelOpenTabs]);
const handleOpenHostFromNotes = useCallback((host: Host, source?: { noteId?: string }) => {
const sourceNoteId = source?.noteId;
@@ -1460,7 +1518,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
notesReturnTabRef.current.set(intent.tabId, intent.returnPanel);
}
openNotesPanelForSourceNote(intent.tabId, intent.noteId);
}, [onOpenVaultNoteFromChat, openNotesPanelForSourceNote]);
}, [onOpenVaultNoteFromChat, openNotesPanelForSourceNote, sidePanelOpenTabsRef]);
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
const text = selection.trim();
@@ -1506,7 +1564,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
handleSwitchSidePanelTab('ai');
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
}, [handleCloseSidePanel, handleSwitchSidePanelTab, sidePanelOpenTabsRef]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((
@@ -1896,6 +1954,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleOpenAI,
handleOpenSystem,
handleOpenNotes,
handleFocusSidePanelPane,
handleSplitSidePanelPane,
handleCloseSidePanelPane,
handleResizeSidePanelSplit,
handleBackFromNotes,
handleOpenHostFromNotes,
handleOsDetected,
@@ -2018,6 +2080,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpPendingUploadsForTab,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelLayouts,
sidePanelPosition,
sidePanelWidth,
sftpAutoSync,
+4 -2
View File
@@ -31,13 +31,15 @@ export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
/** Allow attachments that carry their own prompt context to submit without textarea text. */
allowEmptySubmit?: boolean;
/** Optional styling for the bordered input group inside the form. */
inputGroupClassName?: string;
}
export const shouldSubmitPromptInput = (text: string, allowEmptySubmit = false): boolean =>
Boolean(text.trim()) || allowEmptySubmit;
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
({ className, onSubmit, allowEmptySubmit = false, children, ...props }, ref) => {
({ className, onSubmit, allowEmptySubmit = false, inputGroupClassName, children, ...props }, ref) => {
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -58,7 +60,7 @@ export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
data-allow-empty-submit={allowEmptySubmit ? 'true' : undefined}
{...props}
>
<InputGroup>{children}</InputGroup>
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
</form>
);
},
+12 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { extractDisplayCommand } from './tool-call';
import {
extractDisplayCommand,
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
truncateToolCommandTooltip,
} from './tool-call';
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
@@ -55,6 +59,13 @@ test('plain command passes through unchanged', () => {
assert.equal(extractDisplayCommand({ command: 'ls -la /tmp' }), 'ls -la /tmp');
});
test('limits long command tooltips to a compact single-line preview', () => {
const tooltip = truncateToolCommandTooltip(` echo first\n${'x'.repeat(300)} `);
assert.equal(tooltip.length, MAX_TOOL_COMMAND_TOOLTIP_CHARS);
assert.equal(tooltip.endsWith('…'), true);
assert.equal(tooltip.includes('\n'), false);
});
test('empty / missing args -> null', () => {
assert.equal(extractDisplayCommand(undefined), null);
assert.equal(extractDisplayCommand({ command: '' }), null);
+20 -1
View File
@@ -7,6 +7,18 @@ import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider';
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
export function truncateToolCommandTooltip(
command: string,
maxChars = MAX_TOOL_COMMAND_TOOLTIP_CHARS,
): string {
const normalized = command.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxChars) return normalized;
if (maxChars <= 1) return '…'.slice(0, maxChars);
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
}
/**
* Pull the user-meaningful shell command out of the tool-call args.
*
@@ -251,7 +263,14 @@ export const ToolCall = ({
<span className="text-muted-foreground/40">$ </span>{displayCmd}
</span>
</TooltipTrigger>
<TooltipContent>{displayCmd}</TooltipContent>
<TooltipContent
side="top"
align="start"
collisionPadding={12}
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
>
{truncateToolCommandTooltip(displayCmd)}
</TooltipContent>
</Tooltip>
);
}
+10 -10
View File
@@ -45,7 +45,7 @@ const BUILTIN_AGENTS: AgentInfo[] = [
];
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
<div className="flex items-center justify-between px-3 pb-1.5 pt-1.5">
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
{children}
</span>
@@ -63,7 +63,7 @@ const AgentMenuRow: React.FC<{
<button
onClick={onClick}
className={cn(
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
'flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
isActive && 'bg-muted',
)}
>
@@ -93,7 +93,7 @@ const DiscoveredAgentRow: React.FC<{
};
return (
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
<div className="flex h-9 w-full items-center gap-2.5 rounded px-3 text-xs">
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
<div className="min-w-0 flex-1">
<span className="block truncate text-foreground/86">{agent.name}</span>
@@ -200,19 +200,19 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownTrigger asChild>
<button
type="button"
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
>
<AgentIconBadge
agent={currentAgent}
size="xs"
variant="plain"
className="opacity-78"
className="h-3 w-3 opacity-78"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground/90">
{currentAgent.name}
</span>
<ChevronDown
size={12}
size={10}
className={cn(
'shrink-0 text-muted-foreground/60 transition-transform',
open && 'rotate-180',
@@ -224,7 +224,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
className="w-[256px] overflow-hidden rounded-md border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow
@@ -287,9 +287,9 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<div className="mx-0 my-1 border-t border-border/50" />
<button
onClick={handleManageAgents}
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
className="flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
>
<Settings size={16} className="opacity-72 shrink-0" />
<Settings size={14} className="opacity-72 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
</button>
</DropdownContent>
+97
View File
@@ -5,8 +5,70 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ChatInput from './ChatInput';
import {
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
resolveChatInputAriaHeight,
resolveChatInputMaxHeight,
resolveChatInputResizeHeight,
resolveVisibleChatInputHeight,
resolveVisibleChatInputMaxHeight,
} from './chatInputResize';
import { TooltipProvider } from '../ui/tooltip';
test('clamps composer dragging to the usable pane height', () => {
assert.equal(resolveChatInputMaxHeight(900), CHAT_INPUT_MAX_HEIGHT);
assert.equal(resolveChatInputMaxHeight(180), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputResizeHeight(128, 500, 420, 360), 208);
assert.equal(resolveChatInputResizeHeight(128, 500, 700, 360), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputResizeHeight(300, 500, 300, 360), 360);
});
test('keeps the requested composer height while the pane is temporarily hidden or constrained', () => {
assert.equal(resolveVisibleChatInputMaxHeight(0), null);
assert.equal(resolveVisibleChatInputMaxHeight(Number.NaN), null);
assert.equal(resolveVisibleChatInputHeight(360, 220), 220);
assert.equal(resolveVisibleChatInputHeight(360, 400), 360);
assert.equal(resolveVisibleChatInputHeight(null, 400), null);
});
test('reports an accessible composer height inside the available range', () => {
assert.equal(resolveChatInputAriaHeight(null, CHAT_INPUT_MIN_HEIGHT), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputAriaHeight(360, 220), 220);
assert.equal(resolveChatInputAriaHeight(160, 220), 160);
});
test('renders an accessible composer resize handle', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput value="" onChange={() => {}} onSend={() => {}} />
</TooltipProvider>,
);
assert.match(html, /role="separator"/);
assert.match(html, /aria-orientation="horizontal"/);
assert.match(html, /aria-label="ai\.chat\.resizeInput"/);
assert.match(html, /cursor-ns-resize/);
});
test('expanded composer grows the text area while keeping controls at the bottom', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /data-section="ai-chat-input-body"/);
assert.match(source, /composerHeight != null \? 'flex min-h-0 flex-1 flex-col'/);
assert.match(source, /data-section="ai-chat-input-footer"/);
assert.match(source, /className="shrink-0/);
assert.doesNotMatch(source, /<Expand/);
assert.doesNotMatch(source, /setExpanded/);
});
test('composer resizing also ends when pointer capture is unexpectedly lost', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /resizeStartRef\.current = null;[\s\S]*releasePointerCapture/);
assert.match(source, /onLostPointerCapture=\{handleComposerResizeEnd\}/);
});
test('virtualizes the host mention list without changing its option contract', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
@@ -103,3 +165,38 @@ test('allows terminal-selection-only steering submissions', () => {
/<button[^>]*disabled=""[^>]*aria-label="ai\.codex\.steer\.addInstruction"/,
);
});
test('renders the Catty context usage ring after the model chip', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
contextUsage={{
sessionId: 'session-1',
inputTokens: 64_000,
contextWindow: 128_000,
estimated: true,
}}
/>
</TooltipProvider>,
);
assert.match(html, /role="progressbar"/);
assert.match(html, /stroke-dasharray=/);
assert.match(html, /stroke-dashoffset=/);
assert.match(html, /class="h-4 w-4"/);
assert.doesNotMatch(html, /text-\[7px\]/);
assert.match(html, /aria-valuenow="50"/);
});
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /getSystemSlashCommand/);
assert.match(source, /systemCommand === 'compact'/);
assert.match(source, /canCompact/);
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
+342 -34
View File
@@ -6,8 +6,19 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { filterQuickMessages, buildSlashCommandItems, filterUserSkillsForSlash, getSlashCommandItemKey, isSystemStopSlashCommand, type AIQuickMessage, type SlashCommandItem, type UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
buildSlashCommandItems,
filterQuickMessages,
filterSystemSlashCommands,
filterUserSkillsForSlash,
getSlashCommandItemKey,
getSystemSlashCommand,
SYSTEM_BUILTIN_SLASH_COMMANDS,
type AIQuickMessage,
type SlashCommandItem,
type UserSkillSlashOption,
} from '../../infrastructure/ai/quickMessages';
import { SlashCommandPicker } from './SlashCommandPicker';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -26,12 +37,34 @@ import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile }
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
import {
CHAT_INPUT_DEFAULT_HEIGHT,
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
CHAT_INPUT_PANEL_RESERVE,
resolveChatInputAriaHeight,
resolveChatInputMaxHeight,
resolveChatInputResizeHeight,
resolveVisibleChatInputHeight,
resolveVisibleChatInputMaxHeight,
} from './chatInputResize';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
// Slightly wider for the provider picker so the per-row default-model
// caption doesn't truncate.
const PROVIDER_PICKER_MAX_WIDTH = 320;
const PERMISSION_PICKER_WIDTH = 250;
const MENU_VIEWPORT_GUTTER = 8;
const CONTEXT_USAGE_RING_RADIUS = 10;
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
function formatContextTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 100_000 ? 0 : 1)}K`;
return String(Math.max(0, Math.round(tokens)));
}
/**
* Provider picker payload used by Catty Agent. When set, the model chip
@@ -58,6 +91,10 @@ interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onCompact?: () => void;
/** When false, hide/ignore `/compact` so the composer is not cleared as a silent no-op. */
canCompact?: boolean;
contextUsage?: AgentContextUsage | null;
onSteer?: () => void;
onStop?: () => void;
isStreaming?: boolean;
@@ -110,6 +147,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
onCompact,
canCompact = false,
contextUsage,
onSteer,
onStop,
isStreaming = false,
@@ -140,7 +180,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const composerDisabled = disabled || isSteering;
const [expanded, setExpanded] = useState(false);
const [composerHeight, setComposerHeight] = useState<number | null>(null);
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
@@ -169,6 +211,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const resizeStartRef = useRef<{
pointerId: number;
startY: number;
startHeight: number;
maxHeight: number;
previousUserSelect: string;
pendingY: number | null;
frame: number | null;
} | null>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
@@ -176,6 +227,116 @@ const ChatInput: React.FC<ChatInputProps> = ({
const atMentionListRef = useRef<VariableSizeVirtualListHandle>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const getComposerMaxHeight = useCallback(() => {
const panel = inputShellRef.current?.closest<HTMLElement>('[data-section="ai-chat-panel"]');
return resolveChatInputMaxHeight(
panel?.clientHeight ?? CHAT_INPUT_MAX_HEIGHT + CHAT_INPUT_PANEL_RESERVE,
);
}, []);
const applyPendingComposerResize = useCallback(() => {
const resizeStart = resizeStartRef.current;
if (!resizeStart) return;
resizeStart.frame = null;
if (resizeStart.pendingY == null) return;
const nextHeight = resolveChatInputResizeHeight(
resizeStart.startHeight,
resizeStart.startY,
resizeStart.pendingY,
resizeStart.maxHeight,
);
resizeStart.pendingY = null;
composerDesiredHeightRef.current = nextHeight;
setComposerHeight(nextHeight);
}, []);
const finishComposerResize = useCallback((target?: HTMLDivElement, pointerId?: number) => {
const resizeStart = resizeStartRef.current;
if (!resizeStart) return;
if (resizeStart.frame !== null) {
cancelAnimationFrame(resizeStart.frame);
applyPendingComposerResize();
}
resizeStartRef.current = null;
document.body.style.userSelect = resizeStart.previousUserSelect;
if (target && pointerId != null && target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
}, [applyPendingComposerResize]);
const handleComposerResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !inputShellRef.current) return;
event.preventDefault();
closeAllMenus();
const maxHeight = getComposerMaxHeight();
resizeStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startHeight: inputShellRef.current.getBoundingClientRect().height,
maxHeight,
previousUserSelect: document.body.style.userSelect,
pendingY: null,
frame: null,
};
setComposerMaxHeight(maxHeight);
document.body.style.userSelect = 'none';
event.currentTarget.setPointerCapture(event.pointerId);
}, [closeAllMenus, getComposerMaxHeight]);
const handleComposerResizeMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const resizeStart = resizeStartRef.current;
if (!resizeStart || resizeStart.pointerId !== event.pointerId) return;
resizeStart.pendingY = event.clientY;
if (resizeStart.frame === null) {
resizeStart.frame = requestAnimationFrame(applyPendingComposerResize);
}
}, [applyPendingComposerResize]);
const handleComposerResizeEnd = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
finishComposerResize(event.currentTarget, event.pointerId);
}, [finishComposerResize]);
const handleComposerResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
const maxHeight = getComposerMaxHeight();
const currentHeight = inputShellRef.current?.getBoundingClientRect().height
?? composerHeight
?? CHAT_INPUT_DEFAULT_HEIGHT;
const nextHeight = resolveChatInputResizeHeight(
currentHeight,
0,
event.key === 'ArrowUp' ? -16 : 16,
maxHeight,
);
setComposerMaxHeight(maxHeight);
composerDesiredHeightRef.current = nextHeight;
setComposerHeight(nextHeight);
}, [composerHeight, getComposerMaxHeight]);
useEffect(() => {
const panel = inputShellRef.current?.closest<HTMLElement>('[data-section="ai-chat-panel"]');
if (!panel || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(() => {
const maxHeight = resolveVisibleChatInputMaxHeight(panel.clientHeight);
if (maxHeight == null) return;
setComposerMaxHeight(maxHeight);
setComposerHeight(resolveVisibleChatInputHeight(
composerDesiredHeightRef.current,
maxHeight,
));
});
observer.observe(panel);
return () => observer.disconnect();
}, []);
useEffect(() => () => {
const resizeStart = resizeStartRef.current;
if (!resizeStart) return;
if (resizeStart.frame !== null) cancelAnimationFrame(resizeStart.frame);
document.body.style.userSelect = resizeStart.previousUserSelect;
}, []);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
@@ -285,24 +446,45 @@ const ChatInput: React.FC<ChatInputProps> = ({
() => new Set(quickMessages.map((message) => message.slug)),
[quickMessages],
);
const systemCommandSlugSet = useMemo(
() => new Set<string>(SYSTEM_BUILTIN_SLASH_COMMANDS.map((command) => command.slug)),
[],
);
const filteredQuickMessages = useMemo(
() => filterQuickMessages(quickMessages, slashQuery),
[quickMessages, slashQuery],
() => filterQuickMessages(quickMessages, slashQuery)
.filter((message) => !systemCommandSlugSet.has(message.slug)),
[quickMessages, slashQuery, systemCommandSlugSet],
);
const availableSystemCommands = useMemo(
() => SYSTEM_BUILTIN_SLASH_COMMANDS.filter((command) => (
command.slug !== 'compact' || canCompact
)),
[canCompact],
);
const filteredSystemCommands = useMemo(
() => filterSystemSlashCommands(availableSystemCommands, slashQuery),
[availableSystemCommands, slashQuery],
);
const filteredUserSkills = useMemo(
() => filterUserSkillsForSlash(userSkillOptions, slashQuery)
.filter((skill) => !quickMessageSlugSet.has(skill.slug)),
[userSkillOptions, slashQuery, quickMessageSlugSet],
.filter((skill) => (
!quickMessageSlugSet.has(skill.slug)
&& !systemCommandSlugSet.has(skill.slug)
)),
[userSkillOptions, slashQuery, quickMessageSlugSet, systemCommandSlugSet],
);
const slashCommandItems = useMemo(
() => buildSlashCommandItems(quickMessages, userSkillOptions, slashQuery),
[quickMessages, userSkillOptions, slashQuery],
() => buildSlashCommandItems(quickMessages, userSkillOptions, slashQuery, true)
.filter((item) => item.kind !== 'system' || item.command.slug !== 'compact' || canCompact),
[quickMessages, userSkillOptions, slashQuery, canCompact],
);
const isSlashCatalogEmpty = quickMessages.length === 0 && userSkills.length === 0;
const isSlashCatalogEmpty = quickMessages.length === 0 && userSkills.length === 0 && filteredSystemCommands.length === 0;
const slashPickerNoResultsLabel = isSlashCatalogEmpty
? t('ai.chat.slashEmptyHint')
: t('ai.chat.slashNoResults');
@@ -343,12 +525,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, [closeAllMenus, onChange, slashRange, value]);
const handleSelectSlashCommandItem = useCallback((item: SlashCommandItem) => {
if (item.kind === 'system') {
const command = item.command.slug;
if (command === 'compact') {
if (!canCompact) {
closeAllMenus();
return;
}
onCompact?.();
}
if (command === 'stop') onStop?.();
onChange('');
closeAllMenus();
return;
}
if (item.kind === 'quickMessage') {
insertQuickMessage(item.message);
return;
}
insertUserSkillToken(item.skill);
}, [insertQuickMessage, insertUserSkillToken]);
}, [canCompact, closeAllMenus, insertQuickMessage, insertUserSkillToken, onChange, onCompact, onStop]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
@@ -481,9 +677,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
const handleSubmit = useCallback(
(_text: string, _event: FormEvent<HTMLFormElement>) => {
if (isSystemStopSlashCommand(value)) {
onStop?.();
onChange('');
const systemCommand = getSystemSlashCommand(value);
if (systemCommand) {
if (systemCommand === 'compact') {
if (!canCompact) return;
onCompact?.();
onChange('');
return;
}
if (systemCommand === 'stop') {
onStop?.();
onChange('');
return;
}
return;
}
if (isStreaming && canSteer) {
@@ -492,7 +698,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
onSend();
},
[canSteer, isStreaming, onSend, onSteer, onStop, onChange, value],
[canCompact, canSteer, isStreaming, onCompact, onSend, onSteer, onStop, onChange, value],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
@@ -546,6 +752,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
? 'max-w-[180px]'
: (selectedThinking ? 'max-w-[148px]' : 'max-w-[82px]');
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
const contextUsagePercent = contextUsage
? Math.min(100, Math.max(0, (contextUsage.inputTokens / contextUsage.contextWindow) * 100))
: 0;
const contextUsageRingColor = contextUsagePercent >= 80
? 'stroke-red-400'
: contextUsagePercent >= 50
? 'stroke-amber-400'
: 'stroke-emerald-400';
const contextUsageRingOffset = CONTEXT_USAGE_RING_CIRCUMFERENCE
* (1 - contextUsagePercent / 100);
const contextUsageLabel = contextUsage
? `${contextUsage.estimated ? '~' : ''}${t('ai.chat.contextUsage')}`
.replace('{used}', formatContextTokens(contextUsage.inputTokens))
.replace('{max}', formatContextTokens(contextUsage.contextWindow))
: '';
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
@@ -556,10 +777,42 @@ const ChatInput: React.FC<ChatInputProps> = ({
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<div
ref={inputShellRef}
className="relative"
style={composerHeight == null ? undefined : { height: composerHeight }}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('ai.chat.resizeInput')}
aria-valuemin={CHAT_INPUT_MIN_HEIGHT}
aria-valuemax={composerMaxHeight}
aria-valuenow={Math.round(resolveChatInputAriaHeight(
composerHeight,
composerMaxHeight,
))}
tabIndex={0}
title={t('ai.chat.resizeInput')}
onPointerDown={handleComposerResizeStart}
onPointerMove={handleComposerResizeMove}
onPointerUp={handleComposerResizeEnd}
onPointerCancel={handleComposerResizeEnd}
onLostPointerCapture={handleComposerResizeEnd}
onKeyDown={handleComposerResizeKeyDown}
onDoubleClick={() => {
composerDesiredHeightRef.current = null;
setComposerHeight(null);
}}
className="group/resize absolute inset-x-2 -top-1 z-20 flex h-2 cursor-ns-resize touch-none items-start justify-center outline-none"
>
<span className="mt-[3px] h-0.5 w-10 rounded-full bg-border opacity-0 transition-opacity group-hover/resize:opacity-80 group-focus-visible/resize:opacity-80" />
</div>
<PromptInput
onSubmit={handleSubmit}
allowEmptySubmit={hasTerminalSelectionAttachment}
className={composerHeight == null ? undefined : 'h-full'}
inputGroupClassName={composerHeight == null ? undefined : 'h-full'}
>
{/* File attachment chips */}
{files.length > 0 && (
@@ -616,8 +869,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
}}
/>
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{/* Resizable textarea */}
<div
data-section="ai-chat-input-body"
className={[
'relative',
composerHeight != null ? 'flex min-h-0 flex-1 flex-col' : undefined,
].filter(Boolean).join(' ')}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
@@ -657,22 +919,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
disabled={composerDisabled}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
composerHeight != null ? 'min-h-0 max-h-none flex-1' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
>
<Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div>
{/* @ mention popover */}
@@ -740,13 +990,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
listRef={slashPickerListRef}
listboxId={slashPickerListboxId}
ariaLabel={t('ai.chat.slashCommands')}
systemCommands={filteredSystemCommands}
quickMessages={filteredQuickMessages}
userSkills={filteredUserSkills}
slashCommandItems={slashCommandItems}
activeMenuIndex={activeMenuIndex}
onActiveIndexChange={setActiveMenuIndex}
onSelectQuickMessage={insertQuickMessage}
onSelectSystemCommand={(command) => handleSelectSlashCommandItem({ kind: 'system', command })}
onSelectSkill={insertUserSkillToken}
systemCommandsSectionLabel={t('ai.chat.slashSystemCommands')}
systemCommandDescription={(command) => t(command.descriptionKey)}
quickMessagesSectionLabel={t('ai.chat.slashQuickMessages')}
userSkillsSectionLabel={t('ai.chat.slashUserSkills')}
noResultsLabel={slashPickerNoResultsLabel}
@@ -773,7 +1027,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
)}
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputFooter
data-section="ai-chat-input-footer"
className="shrink-0 gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0"
>
<PromptInputTools className="gap-1 min-w-0">
<Tooltip>
<TooltipTrigger asChild>
@@ -888,6 +1145,48 @@ const ChatInput: React.FC<ChatInputProps> = ({
<span className={`truncate min-w-0 ${modelChipMaxWidth}`}>{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{contextUsage && (
<Tooltip>
<TooltipTrigger asChild>
<div
role="progressbar"
aria-label={contextUsageLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(contextUsagePercent)}
className="relative flex h-4 w-4 shrink-0 items-center justify-center"
>
<svg
aria-hidden="true"
className="h-4 w-4"
viewBox="0 0 28 28"
>
<circle
className="stroke-muted/40"
cx="14"
cy="14"
fill="none"
r={CONTEXT_USAGE_RING_RADIUS}
strokeWidth="3.5"
/>
<circle
className={`${contextUsageRingColor} transition-[stroke-dashoffset] duration-300`}
cx="14"
cy="14"
fill="none"
r={CONTEXT_USAGE_RING_RADIUS}
strokeDasharray={CONTEXT_USAGE_RING_CIRCUMFERENCE}
strokeDashoffset={contextUsageRingOffset}
strokeLinecap="round"
strokeWidth="3.5"
transform="rotate(-90 14 14)"
/>
</svg>
</div>
</TooltipTrigger>
<TooltipContent>{contextUsageLabel}</TooltipContent>
</Tooltip>
)}
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
@@ -1040,7 +1339,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (lockTurnConfiguration) return;
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
if (rect) {
const left = Math.max(
MENU_VIEWPORT_GUTTER,
Math.min(
rect.left,
window.innerWidth - PERMISSION_PICKER_WIDTH - MENU_VIEWPORT_GUTTER,
),
);
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('perm');
} else {
closeAllMenus();
@@ -1070,7 +1378,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Permission mode"
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
className="fixed z-[1000] w-[250px] max-w-[calc(100vw-16px)] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{([
+12 -11
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -37,22 +38,16 @@ test("resolved approval state retains only tool calls still present in messages"
assert.deepEqual([...next], [["live-call", false]]);
});
test("assistant content stays plain while streaming or when markdown is hidden", () => {
test("assistant content stays plain only when markdown is hidden", () => {
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreamingMessage: true,
}), true);
}), false);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: true,
isStreamingMessage: false,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreamingMessage: false,
}), false);
});
test("ChatMessageList uses plain text for the streaming assistant message", () => {
test("ChatMessageList renders markdown for the streaming assistant message", () => {
const messages: ChatMessage[] = [
{
id: "user-1",
@@ -80,9 +75,15 @@ test("ChatMessageList uses plain text for the streaming assistant message", () =
),
);
assert.match(markup, /data-ai-content="plain"/);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /streaming-body/);
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
});
test("streaming markdown keeps the renderer in its animating mode", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
});
test("ChatMessageList hydrates markdown after streaming settles", () => {
+2 -9
View File
@@ -105,16 +105,10 @@ export function shouldProvideVaultArtifactNavigation({
return Boolean(onOpenVaultNote || onOpenVaultHost || onOpenVaultSnippet || onOpenVaultSection);
}
/**
* Streamdown + shiki re-parse is too expensive for per-frame streaming updates.
* Use plain text while the assistant message is still animating; hydrate markdown
* once the turn settles.
*/
export function shouldRenderAssistantAsPlainText(options: {
hideMarkdown: boolean;
isStreamingMessage: boolean;
}): boolean {
return options.hideMarkdown || options.isStreamingMessage;
return options.hideMarkdown;
}
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
@@ -655,7 +649,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
: shouldRenderAssistantAsPlainText({
hideMarkdown,
isStreamingMessage: !!isThisStreaming,
})
? (
<div
@@ -668,7 +661,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
: (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
<div data-ai-content="markdown">
<LazyMessageResponse>
<LazyMessageResponse isAnimating={!!isThisStreaming}>
{message.content}
</LazyMessageResponse>
</div>
+48 -2
View File
@@ -1,6 +1,11 @@
import { MessageSquare, Package } from 'lucide-react';
import { Command, MessageSquare, Package } from 'lucide-react';
import React from 'react';
import type { AIQuickMessage, SlashCommandItem, UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
import type {
AIQuickMessage,
SlashCommandItem,
SystemSlashCommand,
UserSkillSlashOption,
} from '../../infrastructure/ai/quickMessages';
import { getSlashCommandItemId } from '../../infrastructure/ai/quickMessages';
import { ScrollArea } from '../ui/scroll-area';
@@ -8,11 +13,15 @@ export interface SlashCommandPickerProps {
listboxId: string;
ariaLabel: string;
quickMessages: AIQuickMessage[];
systemCommands: SystemSlashCommand[];
userSkills: UserSkillSlashOption[];
slashCommandItems: SlashCommandItem[];
activeMenuIndex: number;
onActiveIndexChange: (index: number) => void;
onSelectQuickMessage: (message: AIQuickMessage) => void;
onSelectSystemCommand: (command: SystemSlashCommand) => void;
systemCommandsSectionLabel: string;
systemCommandDescription: (command: SystemSlashCommand) => string;
onSelectSkill: (skill: UserSkillSlashOption) => void;
quickMessagesSectionLabel: string;
userSkillsSectionLabel: string;
@@ -27,11 +36,15 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
listboxId,
ariaLabel,
quickMessages,
systemCommands,
userSkills,
slashCommandItems,
activeMenuIndex,
onActiveIndexChange,
onSelectQuickMessage,
onSelectSystemCommand,
systemCommandsSectionLabel,
systemCommandDescription,
onSelectSkill,
quickMessagesSectionLabel,
userSkillsSectionLabel,
@@ -66,6 +79,39 @@ export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
</div>
) : (
<>
{systemCommands.length > 0 ? (
<>
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
{systemCommandsSectionLabel}
</div>
{systemCommands.map((command) => {
const idx = slashCommandItems.findIndex(
(item) => item.kind === 'system' && item.command.slug === command.slug,
);
const isActive = idx === activeMenuIndex;
return (
<button
id={`${listboxId}-${command.slug}`}
key={command.slug}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => onActiveIndexChange(idx)}
onClick={() => onSelectSystemCommand(command)}
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] min-w-0">
<Command size={12} className="text-primary/60 shrink-0" />
<span className="text-foreground/90">/{command.slug}</span>
</div>
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62">
{systemCommandDescription(command)}
</div>
</button>
);
})}
</>
) : null}
{quickMessages.length > 0 ? (
<>
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
+45
View File
@@ -0,0 +1,45 @@
export const CHAT_INPUT_MIN_HEIGHT = 112;
export const CHAT_INPUT_MAX_HEIGHT = 420;
export const CHAT_INPUT_DEFAULT_HEIGHT = 128;
export const CHAT_INPUT_PANEL_RESERVE = 112;
export function resolveChatInputMaxHeight(panelHeight: number): number {
return Math.max(
CHAT_INPUT_MIN_HEIGHT,
Math.min(CHAT_INPUT_MAX_HEIGHT, panelHeight - CHAT_INPUT_PANEL_RESERVE),
);
}
export function resolveVisibleChatInputMaxHeight(panelHeight: number): number | null {
if (!Number.isFinite(panelHeight) || panelHeight <= 0) return null;
return resolveChatInputMaxHeight(panelHeight);
}
export function resolveVisibleChatInputHeight(
desiredHeight: number | null,
maxHeight: number,
): number | null {
return desiredHeight == null ? null : Math.min(desiredHeight, maxHeight);
}
export function resolveChatInputAriaHeight(
height: number | null,
maxHeight: number,
): number {
return Math.max(
CHAT_INPUT_MIN_HEIGHT,
Math.min(maxHeight, height ?? CHAT_INPUT_DEFAULT_HEIGHT),
);
}
export function resolveChatInputResizeHeight(
startHeight: number,
startPointerY: number,
pointerY: number,
maxHeight: number,
): number {
return Math.min(
maxHeight,
Math.max(CHAT_INPUT_MIN_HEIGHT, startHeight + startPointerY - pointerY),
);
}
+8 -1
View File
@@ -60,6 +60,10 @@ export interface UseAIChatStreamingParams {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
) => void;
}
export interface UseAIChatStreamingReturn {
@@ -106,6 +110,7 @@ export interface SendToCattyContext {
titleText?: string;
selectedUserSkillSlugs?: string[];
permissionMode?: AIPermissionMode;
forceCompaction?: boolean;
}
export interface SendToExternalContext {
@@ -126,6 +131,7 @@ export function useAIChatStreaming({
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
@@ -187,7 +193,8 @@ export function useAIChatStreaming({
reportStreamError,
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope]);
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
@@ -8,6 +8,13 @@ export interface ActiveCompactionUi {
trigger: ContextPrepareTrigger;
}
export interface AgentContextUsage {
sessionId: string;
inputTokens: number;
contextWindow: number;
estimated: boolean;
}
function statusKeyForTrigger(trigger: ContextPrepareTrigger): string {
switch (trigger) {
case 'step':
@@ -54,3 +61,35 @@ export function resolveCompactionStatusText(
if (statusText.startsWith('ai.')) return translate(statusText);
return statusText;
}
export function useAgentContextUsage(sessionId: string | null | undefined): AgentContextUsage | null {
const [usage, setUsage] = useState<AgentContextUsage | null>(null);
useEffect(() => {
if (!sessionId) {
setUsage(null);
return undefined;
}
const unsubscribe = getAgentRuntime().subscribe((event) => {
const eventSessionId = event.chatSessionId ?? event.sessionId;
if (eventSessionId !== sessionId || event.type !== 'context_snapshot') return;
const snapshot = event.snapshot;
if (
typeof snapshot.contextWindow !== 'number'
|| typeof snapshot.estimatedInputTokens !== 'number'
) {
return;
}
setUsage({
sessionId,
inputTokens: Math.max(0, snapshot.estimatedInputTokens),
contextWindow: Math.max(1, snapshot.contextWindow),
estimated: true,
});
});
return unsubscribe;
}, [sessionId]);
return usage?.sessionId === sessionId ? usage : null;
}
+20
View File
@@ -126,6 +126,26 @@ test('buildManagedAgentState preserves the experimental Codex runtime across pat
assert.equal(state.agents[0].command, '/new/codex');
});
test('buildManagedAgentState preserves Grok runtime across path refreshes', () => {
const state = buildManagedAgentState(
[{
id: 'discovered_grok',
name: 'Grok Build',
command: '/old/grok',
enabled: true,
sdkBackend: 'grok',
grokRuntime: 'streaming-json',
}],
'discovered_grok',
'grok',
{ path: '/new/grok', version: '0.2.118', available: true },
);
assert.equal(state.agents[0].grokRuntime, 'streaming-json');
assert.equal(state.agents[0].command, '/new/grok');
assert.equal(state.agents[0].sdkBackend, 'grok');
});
test('getInitialManagedAgentPaths ignores auto-detected command paths', () => {
const state = buildManagedAgentState(
[],
+7 -24
View File
@@ -25,6 +25,7 @@ loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../../application/i18n/I18nProvider';
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
@@ -277,31 +278,13 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
// When focus is in the find/replace widget, paste into that input instead of the body.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore – paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
void pasteForMonacoEditorCommand({
activeElement: document.activeElement,
readClipboardText: () => readClipboardTextRef.current(),
pasteIntoEditor: () => handlePasteRef.current(),
});
});
editor.focus();
+10 -3
View File
@@ -44,6 +44,7 @@ import {
} from "../../infrastructure/config/storageKeys";
import { logger } from "../../lib/logger";
import { cn } from "../../lib/utils";
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "../terminalLayer/terminalSidePanelChrome";
import { readTextFile } from "../../lib/readTextFile";
import { buildTextFilesZipBlob } from "../../lib/textZip";
import type { Host, VaultNote } from "../../types";
@@ -90,7 +91,7 @@ interface NoteFolderNode {
type NotesToolbarPanel = "search" | null;
const toolbarIconButtonClass = "netcatty-tab h-7 w-7 shrink-0 rounded-md p-0 hover:bg-transparent";
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
const NOTES_TREE_DEFAULT_WIDTH = 300;
const NOTES_TREE_MIN_WIDTH = 220;
@@ -1326,7 +1327,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
style={isSidebarMode ? undefined : { width: treeWidth }}
>
<div className="flex-shrink-0">
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-border/60 px-1.5 py-1">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
"flex items-center gap-1 border-b border-border/60 px-1.5",
)}>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1580,7 +1584,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
{isSidebarMode && overlayNote && (
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-border/60 px-1.5 py-1">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
"flex items-center gap-1 border-b border-border/60 px-1.5",
)}>
<Tooltip>
<TooltipTrigger asChild>
<Button
+23 -8
View File
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'reac
import { useClipboardBackend } from '@/application/state/useClipboardBackend';
import {
buildMonacoPasteEdits,
pasteForMonacoEditorCommand,
readClipboardTextWithFallbacks,
} from '@/infrastructure/monaco/monacoClipboardPaste';
import { useNetcattyMonacoTheme } from '@/infrastructure/monaco/useNetcattyMonacoTheme';
@@ -64,6 +65,7 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
const onSubmitShortcutRef = useRef(onSubmitShortcut);
onSubmitShortcutRef.current = onSubmitShortcut;
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
useImperativeHandle(forwardedRef, () => ({
focus: () => editorRef.current?.focus(),
@@ -82,16 +84,24 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
return () => cancelAnimationFrame(frame);
}, [active, fill, height]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardTextWithFallbacks({
const readClipboardText = useCallback(async (): Promise<string | null> => (
readClipboardTextWithFallbacks({
readNavigator: navigator.clipboard?.readText
? () => navigator.clipboard.readText()
: undefined,
readBridge: readClipboardTextFromBridge,
});
})
), [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
@@ -105,7 +115,7 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
editor.executeEdits('netcatty-paste', buildMonacoPasteEdits(text, selections));
editor.focus();
}, [readClipboardTextFromBridge]);
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
@@ -124,10 +134,15 @@ export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptC
);
}
// Fallback paste path for Electron where Monaco clipboardPasteAction can fail.
// When focus is in the find/replace widget, paste into that input instead of the body.
editor.addCommand(
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyV,
() => {
void handlePasteRef.current();
void pasteForMonacoEditorCommand({
activeElement: document.activeElement,
readClipboardText: () => readClipboardTextRef.current(),
pasteIntoEditor: () => handlePasteRef.current(),
});
},
);
requestAnimationFrame(() => editor.layout());
+70 -6
View File
@@ -37,6 +37,7 @@ import type {
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
isCursorAvailableForMode,
normalizeCodexBridgeError,
@@ -218,6 +219,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
cursor: string;
codebuddy: string;
opencode: string;
grok: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
@@ -283,6 +285,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [opencodeCustomPath, setOpencodeCustomPath] = useState(() => initialManagedPathsRef.current?.opencode ?? "");
const [isResolvingOpencode, setIsResolvingOpencode] = useState(false);
const [grokPathInfo, setGrokPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "grok"),
);
const [grokCustomPath, setGrokCustomPath] = useState(() => initialManagedPathsRef.current?.grok ?? "");
const [isResolvingGrok, setIsResolvingGrok] = useState(false);
const codebuddyManagedAgent = useMemo(
() => externalAgents.find((a) => a.id === "discovered_codebuddy"),
[externalAgents],
@@ -330,7 +338,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode"] as ManagedAgentKey[]) {
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
}, []);
@@ -350,7 +358,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setCursorPathInfo
: agentKey === "codebuddy"
? setCodebuddyPathInfo
: setOpencodePathInfo;
: agentKey === "opencode"
? setOpencodePathInfo
: setGrokPathInfo;
setInfo(result);
@@ -391,7 +401,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setIsResolvingCursor
: agentKey === "codebuddy"
? setIsResolvingCodebuddy
: setIsResolvingOpencode;
: agentKey === "opencode"
? setIsResolvingOpencode
: setIsResolvingGrok;
setResolving(true);
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
@@ -430,7 +442,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setCursorPathInfo
: agentKey === "codebuddy"
? setCodebuddyPathInfo
: setOpencodePathInfo;
: agentKey === "opencode"
? setOpencodePathInfo
: setGrokPathInfo;
setInfo(result);
return result;
}
@@ -468,6 +482,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
},
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
{ key: "opencode", delayMs: 1560, path: initialPaths?.opencode ?? "" },
{ key: "grok", delayMs: 1840, path: initialPaths?.grok ?? "" },
];
const cancelTasks = tasks
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
@@ -689,6 +704,30 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
)));
}, [setExternalAgents]);
const handleGrokRuntimeChange = useCallback((runtime: 'acp' | 'streaming-json') => {
setExternalAgents((agents) => {
const managedId = "discovered_grok";
const existing = agents.find((agent) => agent.id === managedId);
if (existing) {
return agents.map((agent) => (
agent.id === managedId ? { ...agent, grokRuntime: runtime } : agent
));
}
// Path not yet resolved: still persist preference on a disabled placeholder.
return [
...agents,
{
...AGENT_DEFAULTS.grok,
id: managedId,
command: "grok",
enabled: false,
available: false,
grokRuntime: runtime,
},
];
});
}, [setExternalAgents]);
// Validate a custom path for an agent.
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
@@ -701,7 +740,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? codebuddyCustomPath
: agentKey === "opencode"
? opencodeCustomPath
: "";
: agentKey === "grok"
? grokCustomPath
: "";
const result = await resolveAgentPath(agentKey, customPath, {
refreshShellEnv: true,
commandSource: customPath.trim() ? "manual" : "auto",
@@ -713,7 +754,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codexPath: result?.path || customPath.trim() || undefined,
});
}
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, resolveAgentPath, refreshCodexIntegration]);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, grokCustomPath, resolveAgentPath, refreshCodexIntegration]);
const handleResetCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
if (agentKey === "codex") {
@@ -726,6 +767,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodebuddyCustomPath("");
} else if (agentKey === "opencode") {
setOpencodeCustomPath("");
} else if (agentKey === "grok") {
setGrokCustomPath("");
}
const result = await resolveAgentPath(agentKey, "", {
@@ -1118,6 +1161,27 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
/>
</SettingsSection>
<SettingsSection
title={t('ai.grok.title')}
leading={<AgentIconBadge agent={{ id: "grok", icon: "grok", name: "Grok Build" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CopilotCliCard
pathInfo={grokPathInfo}
isResolvingPath={isResolvingGrok}
customPath={grokCustomPath}
onCustomPathChange={setGrokCustomPath}
onRecheckPath={() => void handleCheckCustomPath("grok")}
onResetPath={() => void handleResetCustomPath("grok")}
i18nPrefix="ai.grok"
grokRuntime={
externalAgents.find((a) => a.id === "discovered_grok")?.grokRuntime === "streaming-json"
? "streaming-json"
: "acp"
}
onGrokRuntimeChange={handleGrokRuntimeChange}
/>
</SettingsSection>
{agentOptions.length > 1 && (
<SettingsSection title={t('ai.defaultAgent')}>
<SettingCard>
@@ -91,6 +91,9 @@ interface SettingsSystemTabProps {
setSshDeepLinkEnabled: (enabled: boolean) => void;
jmsDeepLinkEnabled: boolean;
setJmsDeepLinkEnabled: (enabled: boolean) => void;
explorerContextMenuEnabled: boolean;
setExplorerContextMenuEnabled: (enabled: boolean) => void;
explorerContextMenuSupported: boolean;
restorePreviousSession: boolean;
setRestorePreviousSession: (enabled: boolean) => void;
restoreTerminalCwd: boolean;
@@ -129,6 +132,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
explorerContextMenuEnabled,
setExplorerContextMenuEnabled,
explorerContextMenuSupported,
restorePreviousSession,
setRestorePreviousSession,
restoreTerminalCwd,
@@ -1012,6 +1018,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</SettingRow>
</SettingCard>
{explorerContextMenuSupported && (
<>
<SectionHeader title={t('settings.explorerContextMenu.title')} />
<SettingCard>
<SettingRow
label={t('settings.explorerContextMenu.enable')}
description={t('settings.explorerContextMenu.enableDesc')}
>
<Toggle
checked={explorerContextMenuEnabled}
onChange={setExplorerContextMenuEnabled}
ariaLabel={t('settings.explorerContextMenu.enable')}
/>
</SettingRow>
</SettingCard>
</>
)}
<SectionHeader title={t("settings.sshDebugLogs.title")} />
<SettingCard className="min-w-0 max-w-full overflow-hidden space-y-4 py-4">
<SettingRow
@@ -65,6 +65,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.rightClick.fullscreenMenu")}
description={t("settings.terminal.behavior.rightClick.fullscreenMenu.desc")}
>
<Toggle
checked={terminalSettings.showContextMenuOverFullscreenApps}
onChange={(v) => updateTerminalSetting("showContextMenuOverFullscreenApps", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.copyOnSelect")}
description={t("settings.terminal.behavior.copyOnSelect.desc")}
@@ -38,3 +38,38 @@ test("Copilot check button still requires a custom path", () => {
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
});
test("Grok card surfaces ACP runtime toggle when detected", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
onGrokRuntimeChange={() => {}}
/>,
);
assert.match(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP/);
assert.match(markup, /role="switch"/);
});
test("Grok card hides ACP toggle without runtime change handler", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
/>,
);
assert.doesNotMatch(markup, /role="switch"/);
assert.doesNotMatch(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP \(agent stdio\)/);
});
+37 -1
View File
@@ -2,7 +2,9 @@ import React from "react";
import { RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Switch } from "../../../ui/switch";
import { cn } from "../../../../lib/utils";
import type { GrokRuntime } from "../../../../infrastructure/ai/types";
import type { AgentPathInfo } from "./types";
export const CopilotCliCard: React.FC<{
@@ -12,9 +14,12 @@ export const CopilotCliCard: React.FC<{
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath?: () => void;
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode";
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
allowEmptyCheck?: boolean;
showCustomPathInput?: boolean;
/** Grok only: ACP (default) vs headless streaming-json. */
grokRuntime?: GrokRuntime;
onGrokRuntimeChange?: (runtime: GrokRuntime) => void;
}> = ({
pathInfo,
isResolvingPath,
@@ -25,9 +30,12 @@ export const CopilotCliCard: React.FC<{
i18nPrefix = "ai.copilot",
allowEmptyCheck = false,
showCustomPathInput = true,
grokRuntime = "acp",
onGrokRuntimeChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const showGrokRuntime = i18nPrefix === "ai.grok" && typeof onGrokRuntimeChange === "function";
const statusText = isResolvingPath
? t(`${i18nPrefix}.detecting`)
@@ -95,6 +103,34 @@ export const CopilotCliCard: React.FC<{
</div>
</div>
)}
{showGrokRuntime && found && (
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("ai.grok.runtime.acp.title")}</span>
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{t("ai.grok.runtime.acp.default")}
</span>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.acp.description")}
</p>
{grokRuntime === "streaming-json" && (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.streamingJson.hint")}
</p>
)}
</div>
<Switch
checked={grokRuntime === "acp"}
aria-label={t("ai.grok.runtime.acp.title")}
onCheckedChange={(checked) =>
onGrokRuntimeChange?.(checked ? "acp" : "streaming-json")
}
/>
</div>
)}
</div>
);
};
@@ -234,5 +234,6 @@ export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
cursor: getAutoManagedAgentStoredPath(agents, "cursor") ?? "",
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
};
}
+6
View File
@@ -206,6 +206,12 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
icon: "opencode",
sdkBackend: "opencode",
},
grok: {
name: "Grok Build",
args: [],
icon: "grok",
sdkBackend: "grok",
},
};
// ---------------------------------------------------------------------------
+7 -7
View File
@@ -96,7 +96,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return (
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
<DialogContent className="gap-5 p-5 sm:max-w-[640px] sm:p-6">
<DialogHeader className="space-y-2 pr-8">
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
@@ -147,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
)}
</div>
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
<DialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap sm:items-center sm:justify-end sm:space-x-0">
<Button
variant="outline"
onClick={() => handleAction('stop')}
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
className="min-w-24 shrink-0 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
className="min-w-24"
className="min-w-24 shrink-0"
>
{t('sftp.conflict.action.skip')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('duplicate')}
className="min-w-24"
className="min-w-24 shrink-0"
>
{t('sftp.conflict.action.duplicate')}
</Button>
@@ -174,7 +174,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="min-w-24"
className="min-w-24 shrink-0"
>
{t('sftp.conflict.action.merge')}
</Button>
@@ -183,7 +183,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
<Button
variant="default"
onClick={() => handleAction('replace')}
className="min-w-28"
className="min-w-28 shrink-0"
>
{t('sftp.conflict.action.replace')}
</Button>
+17
View File
@@ -50,6 +50,23 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
});
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
assert.match(source, /sftpSourceSessionIdForHost/);
assert.match(
source,
/const sourceSessionId = sftpSourceSessionIdForHost\(\s*item\.entry\.host,\s*item\.entry\.sessionId,\s*\);/,
);
assert.match(
source,
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
);
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
assert.doesNotMatch(
source,
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
);
});
test("sftp host picker virtualizes the host list", () => {
assert.match(source, /VariableSizeVirtualList/);
assert.match(source, /data-host-picker-virtual="sftp"/);
+11 -1
View File
@@ -7,6 +7,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
import { useI18n } from '../../application/i18n/I18nProvider';
import {
sftpHostEndpointsEqual,
sftpSourceSessionIdForHost,
type SftpConnectedHostEntry,
} from '../../domain/sftpConnectedHosts';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
@@ -193,7 +194,16 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
if (item.type === 'local') {
onSelectLocal();
} else if (item.type === 'connected') {
onSelectHost(item.entry.host, { sourceSessionId: item.entry.sessionId });
// Sudo SFTP cannot reuse the terminal shell; omit the hint so connect
// UI (reusedConnection / spinner) matches the dedicated open path.
const sourceSessionId = sftpSourceSessionIdForHost(
item.entry.host,
item.entry.sessionId,
);
onSelectHost(
item.entry.host,
sourceSessionId ? { sourceSessionId } : undefined,
);
} else {
onSelectHost(item.host);
}
@@ -15,6 +15,7 @@ import { ProcessManagerTab } from './ProcessManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from './hooks/useSystemManager';
@@ -182,13 +183,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
return (
<SystemPanelShell section="system-manager-panel">
{workspaceHostHeader}
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/50">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'flex items-center gap-0.5 px-2 border-b border-border/50',
)}>
{tabDefs.filter((tab) => availableTabs.includes(tab.id)).map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] transition-colors',
'h-6 flex items-center gap-1.5 px-2 rounded text-[11px] transition-colors',
resolvedTab === id
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
@@ -21,6 +21,7 @@ const shouldSuppressMouseTrackingContextMenu = (
shouldSuppressMouseTrackingContextMenu?: (options: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
}
).shouldSuppressMouseTrackingContextMenu;
@@ -41,6 +42,7 @@ const shouldOpenTerminalContextMenu = (
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
}
).shouldOpenTerminalContextMenu;
@@ -195,6 +197,47 @@ test("allows reconnect menu while stale mouse tracking is still active", () => {
);
});
test("forceMenuInAlternateScreen opts out of alternate-screen suppression", () => {
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (
typeof shouldSuppressMouseTrackingContextMenu !== "function" ||
typeof shouldOpenTerminalContextMenu !== "function"
) {
return;
}
// Setting on: no suppression, right-click opens the app menu in tmux/vim.
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: true,
}),
false,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting off (default): alternate screen still suppresses the menu.
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: false,
}),
true,
);
});
test("opens a middle-click menu even when right-click is configured to paste", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (typeof shouldOpenTerminalContextMenu !== "function") return;
+20 -6
View File
@@ -44,6 +44,8 @@ export interface TerminalContextMenuProps {
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
showContextMenuOverFullscreenApps?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onUploadClipboardImage?: () => void;
@@ -74,10 +76,12 @@ export const shouldShowReconnectAction = ({
export const shouldSuppressMouseTrackingContextMenu = ({
isAlternateScreen,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction && !forceMenuInAlternateScreen);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
@@ -91,36 +95,42 @@ export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen });
export const shouldAllowSuppressedTerminalContextMenuContent = ({
event,
isAlternateScreen,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
isMiddleClickContextMenuEvent(event.nativeEvent)
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }));
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen }));
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean => {
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
return true;
@@ -130,7 +140,7 @@ export const shouldOpenTerminalContextMenu = ({
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen })) {
return false;
}
@@ -149,6 +159,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
showContextMenuOverFullscreenApps = false,
onCopy,
onPaste,
onUploadClipboardImage,
@@ -235,9 +246,10 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
rightClickBehavior,
isAlternateScreen,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
});
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
e.preventDefault();
return;
}
@@ -254,6 +266,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
event: e,
isAlternateScreen,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
}));
return;
}
@@ -266,7 +279,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, showReconnectAction],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, showReconnectAction, showContextMenuOverFullscreenApps],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure
@@ -283,6 +296,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
}) && (
<ContextMenuContent className="w-max">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
+1
View File
@@ -418,6 +418,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
+8 -4
View File
@@ -21,6 +21,7 @@ import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area';
import { isFollowAppTerminalThemeId } from '../../domain/terminalAppearance';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
type TabType = 'theme' | 'font' | 'custom';
@@ -309,10 +310,13 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
}}
>
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
@@ -323,7 +327,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
</button>
<button
onClick={() => setActiveTab('font')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
@@ -335,7 +339,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{!followAppTerminalTheme && (
<button
onClick={() => setActiveTab('custom')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
@@ -1,5 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractRootPathsFromDropEntries } from './terminalHelpers.ts';
// Inline copy of extractRootPathsFromClipboardFiles for standalone test
function extractRootPathsFromClipboardFiles(
@@ -96,3 +97,18 @@ describe('extractRootPathsFromClipboardFiles', () => {
);
});
});
describe('extractRootPathsFromDropEntries', () => {
it('uses a reconstructed DropEntry local path when File.path is unavailable', () => {
const fileWithoutPath = { name: 'child.txt' } as File;
assert.deepEqual(
extractRootPathsFromDropEntries([{
file: fileWithoutPath,
localPath: '/home/user/folder/child.txt',
relativePath: 'folder/child.txt',
isDirectory: false,
}]),
['/home/user/folder'],
);
});
});
File diff suppressed because it is too large. Load diff
+240 -51
View File
@@ -29,8 +29,6 @@ interface CachedDecorationRange {
priority: number;
}
type HighlightRange = Pick<CachedDecorationRange, 'x' | 'width'>;
interface LogicalDecorationRange {
start: number;
length: number;
@@ -46,7 +44,6 @@ interface DirtyLineSegment {
interface LineDecorationState {
marker: IMarker;
decorations: IDecoration[];
ranges: readonly CachedDecorationRange[];
signature: string;
}
@@ -103,10 +100,11 @@ export class KeywordHighlighter implements IDisposable {
private term: XTerm;
private compiledRules: CompiledRule[] = [];
private lineDecorations = new Map<number, LineDecorationState>();
private decorationsVersion = 0;
private debounceTimer: NodeJS.Timeout | null = null;
/** Single quiet-window catch-up after bulk dumps (no per-write schedule). */
private bulkPressureCatchUpTimer: NodeJS.Timeout | null = null;
private enterInputIdleTimer: NodeJS.Timeout | null = null;
private writePruneTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
@@ -126,6 +124,10 @@ export class KeywordHighlighter implements IDisposable {
private lastWriteAt = 0;
private lastBurstDecayAt = 0;
private lastUserInputAt = 0;
private enterInputPending = false;
private enterQueuedWriteCancellationPending = false;
private enterViewportScanInProgress = false;
private enterViewportScanNeedsRepeat = false;
private scrollRefreshJob: ScrollRefreshJob | null = null;
private scrollRefreshGeneration = 0;
private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding;
@@ -140,6 +142,7 @@ export class KeywordHighlighter implements IDisposable {
private static readonly WRITE_BURST_DEBOUNCE_MS = 180;
private static readonly WRITE_BURST_IMMEDIATE_MIN_INTERVAL_MS = 48;
private static readonly WRITE_BURST_HIGHLIGHT_PAUSE_MS = 260;
private static readonly WRITE_PRUNE_IDLE_MS = 600;
constructor(term: XTerm) {
this.term = term;
@@ -152,13 +155,53 @@ export class KeywordHighlighter implements IDisposable {
}),
// User input should keep terminal echo responsive; highlight can catch up
// once typing pauses.
this.term.onData(() => {
this.term.onData((data) => {
this.lastUserInputAt = performance.now();
if (data.includes("\r") || data.includes("\n")) {
this.enterInputPending = true;
this.enterQueuedWriteCancellationPending = true;
if (this.enterInputIdleTimer) {
clearTimeout(this.enterInputIdleTimer);
this.enterInputIdleTimer = null;
}
}
}),
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
if (this.enterInputPending) {
this.scheduleEnterInputIdleClear();
}
const outputDrivenPendingScroll =
this.pendingRefreshReason === "scroll"
&& (
this.hasOutputPositionChangedSinceLastSnapshot()
|| this.hasDecorationMarkerShiftSinceLastRefresh()
);
const cancelQueuedWriteForEnter =
this.enterQueuedWriteCancellationPending
&& this.pendingRefreshReason === "write";
if (this.enterInputPending || outputDrivenPendingScroll) {
this.cancelScrollRefresh();
if (
this.pendingRefreshReason === "scroll"
|| cancelQueuedWriteForEnter
) {
this.cancelQueuedRefreshSchedule();
}
if (this.pendingRefreshReason === "scroll") {
this.pendingRefreshReason = "write";
}
}
this.enterQueuedWriteCancellationPending = false;
const pressure = getTerminalOutputPressure(this.term);
if (pressure.background || pressure.longLine || pressure.largeOutput) {
if (this.pendingRefreshReason === "write") {
this.cancelQueuedRefreshSchedule();
}
this.enterViewportScanInProgress = false;
this.enterViewportScanNeedsRepeat = false;
}
if (pressure.background) {
// Hidden panes: avoid immediate scans that fight xterm, but still arm a
// debounced refresh. Reveal only flushes writes/repaints — without a
@@ -181,13 +224,29 @@ export class KeywordHighlighter implements IDisposable {
this.scheduleBulkPressureCatchUp();
return;
}
if (this.isInputProtectionActive(performance.now())) {
this.updateWriteBurst();
this.markVisibleRangeDirty();
this.triggerRefresh("debounced", "write");
const inputProtectionActive = this.isInputProtectionActive(performance.now());
if (inputProtectionActive || this.enterInputPending) {
if (this.enterInputPending) {
if (this.enterViewportScanInProgress) {
this.updateWriteBurst();
this.enterViewportScanNeedsRepeat = true;
} else {
this.markDirtyFromWrite({ includeViewportProbe: false });
const buffer = this.term.buffer.active;
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
this.enterViewportScanInProgress = true;
}
} else {
this.updateWriteBurst();
this.markVisibleRangeDirty();
}
this.triggerRefresh(inputProtectionActive ? "debounced" : "immediate", "write");
return;
}
this.markDirtyFromWrite();
if (outputDrivenPendingScroll) {
this.markVisibleRangeDirty();
}
this.triggerRefresh(
"immediate",
"write",
@@ -279,14 +338,6 @@ export class KeywordHighlighter implements IDisposable {
}
}
public getForegroundRanges(lineY: number): readonly HighlightRange[] {
return this.lineDecorations.get(lineY)?.ranges ?? EMPTY_RANGES;
}
public getForegroundRangesVersion(): number {
return this.decorationsVersion;
}
public dispose() {
this.cancelScrollRefresh();
this.clearDecorations();
@@ -300,6 +351,14 @@ export class KeywordHighlighter implements IDisposable {
clearTimeout(this.bulkPressureCatchUpTimer);
this.bulkPressureCatchUpTimer = null;
}
if (this.enterInputIdleTimer) {
clearTimeout(this.enterInputIdleTimer);
this.enterInputIdleTimer = null;
}
if (this.writePruneTimer) {
clearTimeout(this.writePruneTimer);
this.writePruneTimer = null;
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
@@ -364,11 +423,13 @@ export class KeywordHighlighter implements IDisposable {
this.disposeLineDecorations(lineY, state);
}
this.lineDecorations.clear();
if (hadDecorations) this.decorationsVersion += 1;
this.lastViewportRange = null;
this.lastRenderRange = null;
this.clearDirtySegments();
this.dirtyAllInRenderRange = false;
this.enterQueuedWriteCancellationPending = false;
this.enterViewportScanInProgress = false;
this.enterViewportScanNeedsRepeat = false;
if (hadDecorations) {
this.term.refresh(0, this.term.rows - 1);
}
@@ -389,13 +450,13 @@ export class KeywordHighlighter implements IDisposable {
if (lineHint != null) {
const hinted = this.lineDecorations.get(lineHint);
if (hinted === target) {
if (this.lineDecorations.delete(lineHint)) this.decorationsVersion += 1;
this.lineDecorations.delete(lineHint);
return lineHint;
}
}
for (const [mappedLineY, mappedState] of this.lineDecorations) {
if (mappedState === target) {
if (this.lineDecorations.delete(mappedLineY)) this.decorationsVersion += 1;
this.lineDecorations.delete(mappedLineY);
return mappedLineY;
}
}
@@ -420,7 +481,7 @@ export class KeywordHighlighter implements IDisposable {
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (!marker) {
if (this.lineDecorations.delete(lineY)) this.decorationsVersion += 1;
this.lineDecorations.delete(lineY);
return;
}
@@ -439,17 +500,15 @@ export class KeywordHighlighter implements IDisposable {
if (decorations.length === 0) {
marker.dispose();
if (this.lineDecorations.delete(lineY)) this.decorationsVersion += 1;
this.lineDecorations.delete(lineY);
return;
}
this.lineDecorations.set(lineY, {
marker,
decorations,
ranges,
signature,
});
this.decorationsVersion += 1;
this.markTerminalRefreshNeeded(lineY);
}
@@ -657,11 +716,17 @@ export class KeywordHighlighter implements IDisposable {
const previousRange = this.lastRenderRange;
this.beginTerminalRefreshTracking(viewportStart, viewportEnd);
let writeContinuationPending = false;
try {
this.reindexLineDecorationsFromMarkers();
if (reason === "write") {
this.processDirtyLinesInRange(rangeStart, rangeEnd, cursorAbsoluteY, "write");
writeContinuationPending = this.processDirtyLinesInRange(
rangeStart,
rangeEnd,
cursorAbsoluteY,
"write",
);
} else if (reason === "scroll") {
this.startScrollRefresh(viewportStart, viewportEnd, cursorAbsoluteY);
return;
@@ -676,9 +741,18 @@ export class KeywordHighlighter implements IDisposable {
this.processLineRange(rangeStart, rangeEnd, cursorAbsoluteY);
}
for (const [lineY, state] of this.lineDecorations) {
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
this.disposeLineDecorations(lineY, state);
if (reason === "write") {
this.pruneWriteDecorationsIfNeeded(rangeStart, rangeEnd, true);
this.finishEnterViewportScan(
writeContinuationPending,
viewportStart,
viewportEnd,
);
} else {
for (const [lineY, state] of this.lineDecorations) {
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
this.disposeLineDecorations(lineY, state);
}
}
}
@@ -691,12 +765,30 @@ export class KeywordHighlighter implements IDisposable {
} else {
this.lastViewportRange = { start: viewportStart, end: viewportEnd };
this.lastRenderRange = { start: rangeStart, end: rangeEnd };
if (reason === "full" && this.enterViewportScanInProgress) {
this.triggerRefresh("debounced", "write");
}
}
} finally {
this.flushTerminalRefresh();
}
}
private finishEnterViewportScan(
continuationPending: boolean,
viewportStart: number,
viewportEnd: number,
): void {
if (!this.enterViewportScanInProgress || continuationPending) return;
this.enterViewportScanInProgress = false;
if (!this.enterViewportScanNeedsRepeat) return;
this.enterViewportScanNeedsRepeat = false;
this.addDirtyRange(viewportStart, viewportEnd);
this.enterViewportScanInProgress = true;
this.triggerRefresh("continuation", "write");
}
private beginTerminalRefreshTracking(viewportStart: number, viewportEnd: number) {
this.activeRefreshViewport = { start: viewportStart, end: viewportEnd };
this.pendingTerminalRefreshRange = null;
@@ -731,7 +823,6 @@ export class KeywordHighlighter implements IDisposable {
if (this.lineDecorations.size === 0) return;
const nextLineDecorations = new Map<number, LineDecorationState>();
const staleStates = new Set<LineDecorationState>();
const changedLines = new Set<number>();
for (const state of this.lineDecorations.values()) {
if (state.marker.isDisposed || state.marker.line < 0) {
@@ -750,21 +841,7 @@ export class KeywordHighlighter implements IDisposable {
staleStates.delete(state);
}
let mappingChanged = this.lineDecorations.size !== nextLineDecorations.size;
for (const [lineY, state] of this.lineDecorations) {
if (nextLineDecorations.get(lineY) !== state) {
mappingChanged = true;
changedLines.add(lineY);
}
}
for (const [lineY, state] of nextLineDecorations) {
if (this.lineDecorations.get(lineY) !== state) changedLines.add(lineY);
}
if (staleStates.size > 0) mappingChanged = true;
this.lineDecorations = nextLineDecorations;
if (mappingChanged) this.decorationsVersion += 1;
for (const lineY of changedLines) this.markTerminalRefreshNeeded(lineY);
for (const state of staleStates) {
const markerLineBeforeDispose = state.marker.isDisposed ? -1 : state.marker.line;
@@ -781,7 +858,7 @@ export class KeywordHighlighter implements IDisposable {
rangeEnd: number,
cursorAbsoluteY: number,
continuationReason: RefreshReason
) {
): boolean {
if (this.dirtyAllInRenderRange) {
this.dirtySegments = [{ start: rangeStart, end: rangeEnd }];
this.rebuildDirtyLineCount();
@@ -789,7 +866,7 @@ export class KeywordHighlighter implements IDisposable {
}
if (this.dirtySegments.length === 0) {
return;
return false;
}
const dirtyInRange: DirtyLineSegment[] = [];
@@ -803,7 +880,7 @@ export class KeywordHighlighter implements IDisposable {
}
if (dirtyInRange.length === 0) {
return;
return false;
}
const { writeRefreshBudgetMs, dirtySegmentChunkSize } = this.getAdaptiveHighlightingProfile();
@@ -820,14 +897,94 @@ export class KeywordHighlighter implements IDisposable {
if (chunkStart <= segment.end && performance.now() - startTime >= writeRefreshBudgetMs) {
this.triggerRefresh("continuation", continuationReason);
return;
return true;
}
}
if (performance.now() - startTime >= writeRefreshBudgetMs) {
if (
this.dirtySegments.length > 0
&& performance.now() - startTime >= writeRefreshBudgetMs
) {
this.triggerRefresh("continuation", continuationReason);
return;
return true;
}
}
return false;
}
private pruneWriteDecorationsIfNeeded(
rangeStart: number,
rangeEnd: number,
deferUntilIdle = false,
): void {
const renderLineCount = Math.max(1, rangeEnd - rangeStart + 1);
const highWaterMark = Math.max(64, renderLineCount * 2);
if (this.lineDecorations.size <= highWaterMark) return;
if (deferUntilIdle) {
const hardLimit = Math.max(256, highWaterMark * 4);
if (this.lineDecorations.size > hardLimit) {
const trimTarget = Math.max(highWaterMark, Math.floor(hardLimit * 0.75));
this.pruneWriteDecorationsToLimit(rangeStart, rangeEnd, trimTarget);
}
this.scheduleDeferredWritePrune();
return;
}
// Decoration registration/removal makes xterm repaint the full viewport.
// Prune in batches so ordinary one-line output keeps existing highlights
// stable while long-running output remains bounded.
for (const [lineY, state] of this.lineDecorations) {
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
this.disposeLineDecorations(lineY, state);
}
}
}
private pruneWriteDecorationsToLimit(
rangeStart: number,
rangeEnd: number,
targetSize: number,
): void {
for (const [lineY, state] of this.lineDecorations) {
if (this.lineDecorations.size <= targetSize) return;
if (lineY < rangeStart || lineY > rangeEnd || state.marker.isDisposed) {
this.disposeLineDecorations(lineY, state);
}
}
}
private scheduleDeferredWritePrune(): void {
if (this.writePruneTimer) clearTimeout(this.writePruneTimer);
this.writePruneTimer = setTimeout(() => {
this.writePruneTimer = null;
if (!this.enabled || this.compiledRules.length === 0) return;
const now = performance.now();
if (
(
this.lastUserInputAt > 0
&& now - this.lastUserInputAt < KeywordHighlighter.WRITE_PRUNE_IDLE_MS
)
|| (
this.lastWriteAt > 0
&& now - this.lastWriteAt < KeywordHighlighter.WRITE_PRUNE_IDLE_MS
)
) {
this.scheduleDeferredWritePrune();
return;
}
const buffer = this.term.buffer.active;
const overscan = this.getOverscanLines("write");
const rangeStart = Math.max(0, buffer.viewportY - overscan);
const rangeEnd = buffer.viewportY + this.term.rows - 1 + overscan;
this.pruneWriteDecorationsIfNeeded(rangeStart, rangeEnd);
}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
}
private scheduleEnterInputIdleClear(): void {
if (this.enterInputIdleTimer) clearTimeout(this.enterInputIdleTimer);
this.enterInputIdleTimer = setTimeout(() => {
this.enterInputIdleTimer = null;
this.enterInputPending = false;
}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
}
private mergeRefreshReason(current: RefreshReason, next: RefreshReason): RefreshReason {
@@ -850,6 +1007,24 @@ export class KeywordHighlighter implements IDisposable {
};
}
private hasOutputPositionChangedSinceLastSnapshot(): boolean {
const previous = this.lastBufferSnapshot;
const current = this.readBufferSnapshot({ includeViewportProbe: false });
if (!previous || !current) return false;
return (
current.length !== previous.length
|| current.baseY !== previous.baseY
|| current.cursorAbsoluteY !== previous.cursorAbsoluteY
);
}
private hasDecorationMarkerShiftSinceLastRefresh(): boolean {
for (const [lineY, state] of this.lineDecorations) {
if (state.marker.isDisposed || state.marker.line !== lineY) return true;
}
return false;
}
private buildViewportProbe(buffer: IBuffer, rows: number): readonly ViewportProbeSample[] {
if (rows <= 0) return [];
const viewportStart = buffer.viewportY;
@@ -988,9 +1163,9 @@ export class KeywordHighlighter implements IDisposable {
return Math.min(maxDirtyLines, Math.max(minDirtyLines, dynamicDirtyLines));
}
private markDirtyFromWrite() {
private markDirtyFromWrite({ includeViewportProbe = true }: { includeViewportProbe?: boolean } = {}) {
this.updateWriteBurst();
const snapshot = this.readBufferSnapshot();
const snapshot = this.readBufferSnapshot({ includeViewportProbe });
if (!snapshot) {
this.markVisibleRangeDirty();
return;
@@ -1168,6 +1343,9 @@ export class KeywordHighlighter implements IDisposable {
): boolean {
if (mode !== "immediate") return false;
if (!this.isWriteBurstActive(now)) return false;
if (now - this.lastRefreshTime >= KeywordHighlighter.WRITE_BURST_HIGHLIGHT_PAUSE_MS) {
return false;
}
return reason === "write";
}
@@ -1310,6 +1488,17 @@ export class KeywordHighlighter implements IDisposable {
this.scrollRefreshGeneration += 1;
}
private cancelQueuedRefreshSchedule() {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
private clearLineDecorationsOutsideRange(start: number, end: number) {
if (this.lineDecorations.size === 0) return;
const entries = Array.from(this.lineDecorations.entries());
@@ -586,9 +586,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
...(attempt.useSshAgent === false
? { useSshAgent: false }
: resolveBridgeSshAgentAuth(ctx.host, attempt.key, authMethod)),
...resolveBridgeSshAgentAuth(
attempt.useSshAgent === false
? { ...ctx.host, useSshAgent: false }
: ctx.host,
attempt.key,
authMethod,
),
knownHosts: ctx.knownHosts,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
// Ask the bridge to reuse the source tab's authenticated connection
@@ -2044,11 +2044,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const keywordHighlighter = new KeywordHighlighter(term);
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
const cursorLineHighlighter = new CursorLineHighlighter(
term,
(absoluteLine) => keywordHighlighter.getForegroundRanges(absoluteLine),
() => keywordHighlighter.getForegroundRangesVersion(),
);
const cursorLineHighlighter = new CursorLineHighlighter(term);
cursorLineHighlighter.setBackgroundColor(
resolveCursorLineHighlightBackground(ctx.terminalTheme.colors),
);
@@ -246,71 +246,39 @@ test('CursorLineHighlighter gives selection and search matches priority', () =>
test('CursorLineHighlighter refreshes after hidden writes become visible', () => {
const term = createFakeTerm(10);
let protectedRanges: Array<{ x: number; width: number }> = [];
const highlighter = new CursorLineHighlighter(
term as never,
() => protectedRanges,
);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.options.width, 10);
assert.equal(term.markers.at(-1)?.line, 0);
term.writeHiddenOutput(1);
protectedRanges = [{ x: 0, width: 2 }];
assert.equal(term.decorations.length, 1);
term.render();
assert.deepEqual(
term.decorations
.filter(({ disposed }) => !disposed)
.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 2, width: 8 },
],
);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.markers.at(-1)?.line, 1);
highlighter.dispose();
});
test('CursorLineHighlighter refreshes after keyword ranges are removed', () => {
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
const term = createFakeTerm(10);
let protectedRanges: Array<{ x: number; width: number }> = [{ x: 0, width: 2 }];
let protectedRangesVersion = 1;
const highlighter = new CursorLineHighlighter(
term as never,
() => protectedRanges,
() => protectedRangesVersion,
);
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.filter(({ disposed }) => !disposed).map(({ options }) => options.width),
[8],
);
protectedRanges = [];
protectedRangesVersion += 1;
term.render();
assert.deepEqual(
term.decorations.filter(({ disposed }) => !disposed).map(({ options }) => options.width),
[10],
);
highlighter.dispose();
});
test('CursorLineHighlighter leaves keyword decoration ranges untouched', () => {
const term = createFakeTerm(10);
const highlighter = new CursorLineHighlighter(
term as never,
() => [{ x: 2, width: 2 }],
);
const keywordMarker = term.registerMarker(0);
term.registerDecoration({
marker: keywordMarker,
x: 2,
width: 2,
foregroundColor: '#F87171',
});
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 2 },
{ x: 4, width: 6 },
],
);
assert.equal(term.decorations[0]?.options.foregroundColor, '#F87171');
assert.equal(term.decorations[0]?.options.backgroundColor, undefined);
assert.equal(term.decorations[1]?.options.x, 0);
assert.equal(term.decorations[1]?.options.width, 10);
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[1]?.options.layer, 'bottom');
highlighter.dispose();
});
@@ -37,24 +37,16 @@ export class CursorLineHighlighter implements IDisposable {
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
private activeProtectedRangesVersion = 0;
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
constructor(
private readonly term: CursorLineTerminal,
private readonly getProtectedRanges: (absoluteLine: number) => readonly HighlightRange[] = () => [],
private readonly getProtectedRangesVersion: () => number = () => 0,
) {
constructor(private readonly term: CursorLineTerminal) {
this.disposables.push(
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
if (
this.pendingRefresh ||
this.getProtectedRangesVersion() !== this.activeProtectedRangesVersion
) {
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
@@ -109,10 +101,8 @@ export class CursorLineHighlighter implements IDisposable {
line,
cols,
buffer.getNullCell(),
this.getProtectedRanges(absoluteLine),
);
const tailRanges = this.getTailRanges(contentEnd, cols);
const protectedRangesVersion = this.getProtectedRangesVersion();
if (
!options.force &&
@@ -125,7 +115,6 @@ export class CursorLineHighlighter implements IDisposable {
!this.marker.isDisposed &&
this.marker.line === absoluteLine
) {
this.activeProtectedRangesVersion = protectedRangesVersion;
return;
}
@@ -175,7 +164,6 @@ export class CursorLineHighlighter implements IDisposable {
this.activeColor = color;
this.activeRanges = ranges;
this.activeTailRanges = tailRanges;
this.activeProtectedRangesVersion = protectedRangesVersion;
}
dispose(): void {
@@ -192,7 +180,6 @@ export class CursorLineHighlighter implements IDisposable {
line: ReturnType<CursorLineTerminal['buffer']['active']['getLine']>,
cols: number,
cell: ReturnType<CursorLineTerminal['buffer']['active']['getNullCell']>,
protectedRanges: readonly HighlightRange[],
): { ranges: HighlightRange[]; contentEnd: number } {
const ranges: HighlightRange[] = [];
let rangeStart: number | null = null;
@@ -205,15 +192,11 @@ export class CursorLineHighlighter implements IDisposable {
) {
contentEnd = Math.min(cols, x + Math.max(1, currentCell.getWidth()));
}
const isProtected = protectedRanges.some(
(range) => x >= range.x && x < range.x + range.width,
);
const isHighlightable =
!isProtected &&
(currentCell === undefined ||
(currentCell.isBgDefault() &&
currentCell.isFgDefault() &&
!currentCell.isInverse()));
currentCell === undefined ||
(currentCell.isBgDefault() &&
currentCell.isFgDefault() &&
!currentCell.isInverse());
if (isHighlightable && rangeStart === null) rangeStart = x;
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
@@ -68,6 +68,42 @@ test("mouse-tracking context menu capture lets Shift-modified mouse events pass
);
});
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
false,
);
// Setting on but paste behavior: still intercept (setting is menu-only).
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "paste",
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting off (default): still intercept even for context-menu behavior.
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: false,
}),
true,
);
});
test("Shift selection replay events are identifiable", () => {
const event = {} as MouseEvent;
@@ -192,6 +228,31 @@ test("Shift right-click mousedown is stopped while connected mouse tracking is a
);
});
test("right-click mousedown is stopped when the fullscreen-apps menu setting forces the context menu", () => {
// Unmodified right-click + setting on + context-menu behavior: stop it, like Shift+right-click.
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting on but paste behavior: do not stop (menu-only setting).
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "paste",
forceMenuInAlternateScreen: true,
}),
false,
);
});
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
const baseEvent = {
button: 2,
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more