Compare commits

...

46 Commits

Author SHA1 Message Date
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

* feat(shortcuts): Ctrl+W closes the tab directly (drop side-panel priority)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(shortcuts): register toggle-side-panel binding (default ⌘/Ctrl+\)

* feat(shortcuts): add side-panel toggle handler + last-panel memory in TerminalLayer

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫
6e9e8fc40d feat(autocomplete): make snippets a first-class terminal completion source (#1097)
* feat(autocomplete): add snippet completion source

* feat(autocomplete): merge snippets at the command position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(autocomplete): place snippet tests where the test runner finds them

The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autocomplete): wire snippets into the terminal + preview snippet command

* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history

The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers

In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun

Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:03:33 +08:00
陈大猫
67448cea65 feat(terminal): configurable startup-command delay + multi-line sequencing (#1096)
* feat(terminal): add global startupCommandDelayMs setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): add startup-command line-split and delay-clamp helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): run multi-line startup commands in sequence with configurable delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): expose startup command delay in Terminal settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(terminal): keep startup-command line content verbatim

splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): include startupCommandDelayMs in synced terminal settings

Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:47:00 +08:00
陈大猫
770b06a9ee fix(ai): make Claude Code agent diagnosable + configurable (auth) (#1095)
* feat(ai): add Claude auth-presence detection helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ai): surface actionable Claude auth errors and reap stuck agent processes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): add pure helpers for Claude config dir + env editor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ai): let users set Claude config dir and env vars in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(ai): harden Claude config env, de-dupe error text, label a11y

- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
  if a user types them into the free-text env editor, so they can't clobber
  the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
  so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
  via htmlFor/id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): make the Claude auth & config section collapsible

The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): preserve raw env editor text; don't encourage plaintext secrets

P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).

P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): expand ~ in Claude config directory before passing to the agent

CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): recreate cached Claude provider when its env config changes

Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:59:48 +08:00
陈大猫
1d50b2c4a1 feat(terminal): choose dark/light terminal theme when following app theme (#1094)
* feat(terminal): add per-mode follow-theme resolver and storage keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): persist per-mode follow-theme selections in settings state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync): include per-mode follow terminal themes in cloud sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(terminal): add per-mode follow-theme picker strings

* feat(terminal): add type filter and auto option to theme picker

* feat(terminal): pick dark/light terminal theme when following app theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): don't flag the auto sentinel as a missing theme in the picker

The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(terminal): align zh-CN dark/light wording with app convention

Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): match follow-theme preview fallback to runtime resolution

The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:20:45 +08:00
陈大猫
453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-05-25 15:19:27 +08:00
陈大猫
a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫
e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫
38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫
fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫
e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
陈大猫
bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #826: optional Option+←/→ word jump on macOS

Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).

The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #826: gate Option+←/→ word jump to macOS

The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:10:40 +08:00
陈大猫
f9d00c9d23 fix #1079: preserve remote file mode when rz overwrites a same-named file (#1081)
* fix #1079: preserve remote file mode when rz overwrites a same-named file

#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)

Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1079: probe file mode with `stat -- "$n"` for dash-prefixed names

Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:44:41 +08:00
陈大猫
8fd7ff6475 fix #1078: send macOS Option as Meta (wire altAsMeta to xterm macOptionIsMeta) (#1080)
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.

Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:30:35 +08:00
陈大猫
02c80ae7d2 chore: silence two production build warnings (#1072)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫
e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫
78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: handle remote filename conflicts in rz handleUpload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: SSH exec probe + remove for rz upload conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: IPC for rz overwrite-conflict prompt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: renderer prompt for rz overwrite conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: make upload plan index-based to preserve per-file decisions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫
c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫
a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫
74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
陈大猫
60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #1005: add live-preview keystroke calculator for popup autocomplete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render the selected popup suggestion on arrow navigation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render full path while navigating sub-directory panels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test #1005: move live-preview test into the npm test glob

The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: center and refine the popup key-cap hint

Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: record the actual executed line on Enter, not the stale suggestion

Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: don't revert user edits when Escape closes the popup

Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:58:57 +08:00
陈大猫
ee2c21e712 feat #1044: close tabs with the middle mouse button (#1058)
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.

A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:58:19 +08:00
陈大猫
e678ad3546 fix terminal exit auto close (#1057) 2026-05-22 22:49:15 +08:00
陈大猫
c47c780b48 fix s3 checksum compatibility (#1056) 2026-05-22 22:41:25 +08:00
陈大猫
88074ac9b3 fix auto sync remote checks (#1055) 2026-05-22 22:26:05 +08:00
陈大猫
59cb0c4b65 fix #1043: skip pwd probe on network devices to keep Huawei VRP sessions alive (#1052) 2026-05-22 22:06:03 +08:00
陈大猫
bf0bd193eb fix #1049: clear WebGL texture atlas to recover from garbled terminal (#1050)
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).

Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:

- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
  matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
  and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
  down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
  Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:25:38 +08:00
陈大猫
7661375925 fix huawei vrp ssh detection (#1046) 2026-05-22 01:05:46 +08:00
陈大猫
308fb45985 fix comware legacy ssh handshake (#1045) 2026-05-22 00:13:59 +08:00
陈大猫
f4aa6ddb46 fix #1013: stop ghost text from drawing over untracked echoed input (#1042)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length)
after the cursor, where trackedInput is a client-side reconstruction of the
command line (buffer heuristics + keystroke prediction, to mask SSH echo
latency). On hosts with non-standard echo — hardware bastion hosts / network OS
like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and
the ghost gets painted over characters the user already typed (`int` + ghost
`terface` -> `intterface`).

Add a fail-safe consistency check: on each post-echo render, if the real
terminal line before the cursor contains the tracked input followed by more
untracked, non-whitespace characters (reality is AHEAD of what we tracked),
hide the ghost instead of drawing it over real text. SSH echo latency is the
opposite case (the line is a prefix-behind of the tracked input) and is
deliberately not flagged, so the ghost stays responsive on slow links. The
check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so
it can only ever suppress a ghost that would otherwise corrupt — never change
correct behaviour.

This converts the recurring "ghost shows already-typed characters" bug into
"ghost simply doesn't show" on devices we can't track reliably.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:40:35 +08:00
陈大猫
f6cb73fdd6 fix #1040: unique macOS Mach-O LC_UUID for Local Network privacy (#1041)
macOS keys the "Local Network" privacy permission on the main executable's
Mach-O LC_UUID (Apple TN3179). Electron's prebuilt binary is linked with LLD,
which derives the UUID from a content hash, so every app built from the same
Electron version ships the *same* LC_UUID even with a different bundle id. That
collision makes the grant unreliable: a user who enables Local Network for
Netcatty can still hit `connect EHOSTUNREACH` on LAN / VMware host-only
addresses, while loopback-forwarded connections work.

Add an electron-builder afterPack hook that rewrites the packaged macOS
executable's LC_UUID to a value derived deterministically from the appId —
stable across builds (so the grant survives updates) but distinct from every
other app. It runs before code signing, so signature/notarization cover the
patched binary. No-op on Windows/Linux.

Verified the rewrite on a copy of Electron's binary (LC_UUID changes, file
stays a valid Mach-O, deterministic) and added unit tests for the Mach-O
patcher (thin + fat) and the UUID derivation.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:11:25 +08:00
陈大猫
3c100b0ae2 fix #1035: support diffie-hellman-group1-sha1 under BoringSSL (#1039)
Electron's BoringSSL dropped several standard MODP groups from the named
crypto.createDiffieHellmanGroup() API — notably the 1024-bit Oakley Group 2
(modp2) that backs SSH's diffie-hellman-group1-sha1. ssh2 calls
createDiffieHellmanGroup('modp2') for that kex, so connecting to legacy
network devices that only speak group1-sha1 failed with "Error: Unknown DH
group".

The underlying DH math still works on BoringSSL via createDiffieHellman()
with an explicit prime, so add a compatibility shim that wraps
createDiffieHellmanGroup and falls back to the well-known prime constants
when (and only when) the runtime can't resolve a group by name. On OpenSSL
builds the original call succeeds and the fallback is never used.

The shim is installed in main.cjs before any ssh2-using bridge loads, since
ssh2 destructures createDiffieHellmanGroup at module load. Once installed,
the existing legacy-group probe detects modp2 as supported again and offers
group1-sha1, so affected devices actually connect (still gated behind the
per-host legacy-algorithms toggle).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:51:50 +08:00
陈大猫
168e42b5fa fix slow first SSH connect from DH group probe under BoringSSL (#1038)
The fixed-DH-group support probe called crypto.createDiffieHellmanGroup()
for each MODP group to feature-detect runtime support. Under Electron's
BoringSSL, instantiating the large groups is pathologically slow
(modp18/8192-bit takes ~20s on first call), and the result is only cached
in-process, so the first connection after every app launch froze for ~24s.

The standard modern groups (modp14/16/18) are universally supported and
always pass the probe anyway, so treat them as supported without probing.
Only groups a runtime may genuinely drop (e.g. BoringSSL removed the weak
1024-bit group1/modp2) are still feature-detected; those fail instantly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:35:20 +08:00
陈大猫
2ce6bd5ed1 [codex] Reorder legacy SSH group-exchange fallback (#1034)
* reorder legacy ssh kex fallback

* add ssh handshake debug logging
2026-05-21 11:30:24 +08:00
陈大猫
7bd5d6465a fix claude system cli detection (#1033) 2026-05-21 00:11:51 +08:00
陈大猫
65387d4c61 fix legacy group exchange sha1 (#1032) 2026-05-20 23:19:07 +08:00
yuzifu
6084e8e94f fix(terminal): handle forced prompt newline (#1025)
* fix(terminal): handle forced prompt newline

* fix review issue

* fix(terminal): harden prompt newline handling

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-20 23:08:42 +08:00
陈大猫
3ccc5c9fc6 Fix broadcast hotkey refresh (#1030) 2026-05-20 16:30:25 +08:00
陈大猫
d07859f604 [codex] Prevent terminal host preference pollution (#1026)
* Prevent terminal host preference pollution

* Preserve terminal host updates while isolating session ports
2026-05-20 11:51:54 +08:00
陈大猫
88a322a03b [codex] Filter terminal cursor replies from broadcast input (#1022)
* Filter terminal CPR from broadcast input

* Handle split cursor reports in broadcast
2026-05-20 11:12:27 +08:00
陈大猫
0e02bbc2fb [codex] Persist vault host sort mode (#1021)
* Persist vault host sort mode

* Harden vault host sort persistence tests
2026-05-20 10:53:20 +08:00
陈大猫
affd9217e2 Fix session log capture after reconnect (#1020) 2026-05-20 10:53:04 +08:00
陈大猫
7b4a349e3f [codex] Guard unsupported legacy SSH groups (#1023)
* Guard legacy SSH DH groups

* Align legacy SFTP algorithms
2026-05-20 10:52:52 +08:00
陈大猫
7dc5ab5035 [codex] Use terminal cwd when opening SFTP (#1024)
* Use terminal cwd when opening SFTP

* Clear stale terminal cwd for SFTP open
2026-05-20 10:52:35 +08:00
yuzifu
3e8965f9a9 Fix pr987 (#1010) 2026-05-19 20:13:16 +08:00
陈大猫
23a27bf544 Handle missing streamed tool call ids (#1007) 2026-05-19 11:29:50 +08:00
127 changed files with 9768 additions and 753 deletions

39
App.tsx
View File

@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveHostTerminalThemeId,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
@@ -1192,12 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const toggleSidePanelRef = useRef<(() => void) | null>(null);
// Populated below so the hotkey dispatcher can open the Settings window
// even though `handleOpenSettings` is declared further down in the file.
const handleOpenSettingsRef = useRef<() => void>(() => {});
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
@@ -1377,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
@@ -1397,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
@@ -1476,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
@@ -1727,7 +1728,9 @@ function App({ settings }: { settings: SettingsState }) {
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
updateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));
}, [hosts, updateHosts]);
// Wrapper to create serial session with logging
@@ -1756,15 +1759,10 @@ function App({ settings }: { settings: SettingsState }) {
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
// Prefer the persisted sessionId because the session may already have been
// removed from state by the time the terminal unmount cleanup runs.
const matchingLog = connectionLogs
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === sessionId;
return !!session && log.hostname === session.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
const matchingLog = selectConnectionLogForTerminalDataCapture(
connectionLogs,
{ sessionId, hostname: session?.hostname },
);
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
@@ -2145,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -264,6 +264,10 @@ const en: Messages = {
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -301,6 +305,9 @@ const en: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
@@ -359,6 +366,9 @@ const en: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
@@ -1935,13 +1945,20 @@ const en: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
@@ -2092,6 +2109,11 @@ const en: Messages = {
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};

View File

@@ -264,6 +264,10 @@ const ru: Messages = {
'settings.terminal.theme.selectButton': 'Выбрать тему',
'settings.terminal.theme.followApp': 'Следовать теме приложения',
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
'settings.terminal.section.font': 'Шрифт',
'settings.terminal.section.cursor': 'Курсор',
'settings.terminal.section.keyboard': 'Клавиатура',
@@ -301,6 +305,9 @@ const ru: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
'settings.terminal.keyboard.altAsMeta.desc':
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
@@ -359,6 +366,9 @@ const ru: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
'settings.terminal.scrollback.rows': 'Количество строк *',
'settings.terminal.section.startupCommand': 'Команда запуска',
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
@@ -469,6 +479,7 @@ const ru: Messages = {
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
@@ -1967,13 +1978,20 @@ const ru: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Использует claude-agent-acp для потоковой передачи по протоколу ACP.',
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
'ai.claude.detecting': 'Обнаружение...',
'ai.claude.detected': 'Обнаружен',
'ai.claude.notFound': 'Не найден',
'ai.claude.path': 'Путь:',
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
'ai.claude.configDir': 'Каталог конфигурации',
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.envVars': 'Переменные окружения',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check': 'Проверить',
// AI GitHub Copilot CLI
@@ -2124,6 +2142,11 @@ const ru: Messages = {
'zmodem.uploading': 'Загрузка',
'zmodem.downloading': 'Скачивание',
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
};

View File

@@ -1409,6 +1409,10 @@ const zhCN: Messages = {
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1445,6 +1449,8 @@ const zhCN: Messages = {
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
@@ -1497,6 +1503,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.section.startupCommand': '启动命令',
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
@@ -1597,6 +1606,7 @@ const zhCN: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -1944,13 +1954,20 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '认证与配置(可选)',
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude留空用默认',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE传给 Claude agent。明文存在本地——API key凭据建议用上面的「配置目录」claude 登录),不要放这里。',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
@@ -2101,6 +2118,11 @@ const zhCN: Messages = {
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'zmodem.overwrite.title': '远端已存在同名文件',
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
'zmodem.overwrite.overwrite': '覆盖',
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
};

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});

View File

@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}

View File

@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,6 +1,5 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events keep the tab and mark it disconnected", () => {
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});

View File

@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
_evt: TerminalSessionExitEvent,
evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
// Explicit user closes bypass this policy and call the close-session path directly.
if (evt.reason === "exited") {
return { kind: "closeSession" };
}
// Timeouts, transport errors, and channel closes should keep the tab visible
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}

View File

@@ -52,14 +52,19 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args or ACP config differ
// Check if args, ACP config, or Claude's resolved system path differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
if (currentArgs !== newArgs || acpChanged || envChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
}
return ea;
});
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
};
},
[],

View File

@@ -16,6 +16,7 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
@@ -31,6 +32,10 @@ import {
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
@@ -95,6 +100,11 @@ interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
@@ -402,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
@@ -494,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -49,7 +51,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -254,6 +256,12 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
@@ -536,6 +544,10 @@ export const useSettingsState = () => {
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
@@ -669,6 +681,12 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
setTerminalThemeDarkId(value);
}
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
setTerminalThemeLightId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
@@ -862,6 +880,15 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync per-mode follow terminal themes from other windows
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
const next = e.newValue;
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
}
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
const next = e.newValue;
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
@@ -1011,6 +1038,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -1293,25 +1332,32 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
// When "Follow Application Theme" is enabled, honor the per-mode override
// (or auto-match the active UI theme preset when set to auto).
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const followedId = resolveFollowedTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
// Explicit override pointing at a deleted theme: fall through to the
// manual theme below.
}
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1348,6 +1394,10 @@ export const useSettingsState = () => {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,

View File

@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
const bridge = netcattyBridge.get();
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,

View File

@@ -43,6 +43,8 @@ import {
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -161,10 +163,11 @@ interface SyncPayloadImporters {
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
@@ -186,6 +189,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -309,6 +314,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
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);
@@ -432,6 +441,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
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));

View File

@@ -637,6 +637,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
undefined,
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this

View File

@@ -65,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}

View File

@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
@@ -29,7 +28,7 @@ import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
@@ -49,17 +48,20 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
createPromptLineBreakState,
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./terminal/runtime/promptLineBreak";
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -68,7 +70,8 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
@@ -171,6 +174,7 @@ interface TerminalProps {
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
onOpenScripts?: () => void;
onOpenTheme?: () => void;
isBroadcastEnabled?: boolean;
@@ -261,6 +265,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
onTerminalCwdChange,
onOpenScripts,
onOpenTheme,
isBroadcastEnabled,
@@ -281,6 +286,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
const knownCwdRef = useRef<string | undefined>(undefined);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
@@ -293,7 +299,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const terminalLogDataRef = useRef("");
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
@@ -314,21 +320,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const captureTerminalLogData = useCallback((data: string) => {
const replaySafeData = terminalLogSanitizerRef.current.append(data);
if (!replaySafeData) return;
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
connectionLogBufferRef.current.append(replaySafeData);
}, []);
const finalizeTerminalLogData = useCallback(() => {
const replaySafeData = terminalLogSanitizerRef.current.finish();
if (replaySafeData) {
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
connectionLogBufferRef.current.append(replaySafeData);
}
return terminalLogDataRef.current;
return connectionLogBufferRef.current.toString();
}, []);
const writeLocalTerminalData = useCallback((data: string) => {
@@ -383,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
// xterm runtime (and a few effects here) can drive the hook without making
// Terminal re-render on every suggestion update.
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -514,10 +517,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (ch === "\r" || ch === "\n") {
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
markPromptLineBreakCommandPending(promptLineBreakStateRef);
const rawCommand = commandBufferRef.current;
recordTerminalCommandExecution(rawCommand, {
host,
sessionId,
onCommandExecuted,
commandBufferRef,
promptLineBreakStateRef,
}, termRef.current);
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
@@ -531,35 +538,54 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
});
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
// its state updates don't re-render this component (see render below).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux");
const autocompleteSettings = terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined;
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
sessionId: sessionRef.current,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
});
return cwd ?? undefined;
}, [terminalBackend, terminalCwdTracker]);
const clearTerminalCwd = useCallback(() => {
terminalCwdTracker.clearRendererCwd();
knownCwdRef.current = undefined;
onTerminalCwdChange?.(sessionId, null);
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
useEffect(() => {
knownCwdRef.current = undefined;
}, [sessionId, host.id]);
clearTerminalCwd();
return clearTerminalCwd;
}, [clearTerminalCwd, host.id]);
// Classify the host's device family from the *detected* distro and the
// explicit deviceType only. This intentionally bypasses
// getEffectiveHostDistro(): the manual distro override (`distroMode:
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
// Huawei host must not silently re-enable POSIX-shell probes against it.
// Several features gate on this — the working-directory probe below, the
// /etc/os-release probe, and the periodic server-stats poll (#674) —
// because each opens an extra exec channel that strict network-device
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
// whole session (#1043).
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
@@ -569,10 +595,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
const id = sessionRef.current;
if (!id) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (!cancelled && result.success && result.cwd) {
// The pwd probe opens an extra POSIX-shell exec channel, which strict
// network-device CLIs like Huawei VRP answer by closing the whole
// session (#1043). Skip it for known network devices; for a brand-new
// host (distro not classified yet on the first connect) consult the
// SSH banner, which is captured for free at handshake time.
const info = await terminalBackend.getSessionRemoteInfo?.(id);
if (cancelled || id !== sessionRef.current) return;
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
return;
}
const result = await terminalBackend.getSessionPwd(id);
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
} catch {
@@ -584,37 +621,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend]);
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
autocompleteCloseRef.current?.();
}
}, [isVisible, autocompleteClosePopup]);
}, [isVisible]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
// for hosts classified as network devices (either via explicit
// deviceType='network' or via SSH banner detection that populated
// host.distro with a network-vendor ID). See #674: polling the stats
// command on Cisco / Huawei / Juniper etc. generates one AAA session
// log entry per poll because each exec channel is counted as a new
// session on those devices.
//
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
// because that honors the manual distro override (`distroMode: 'manual'`
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
// pinned an "ubuntu" icon on what is actually a Cisco host would
// otherwise silently re-enable the polling loop and re-introduce the
// AAA log flood this patch is meant to eliminate. The display icon can
// still be overridden (see DistroAvatar) — gating uses the raw detected
// `host.distro` and the explicit `host.deviceType` only.
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
// network devices. See isNetworkDevice above for why the gating uses the
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
// #674 covers the AAA-log-flood motivation for stats specifically.
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
@@ -864,6 +886,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setChainProgress,
t,
onSessionAttached: (id: string) => {
clearTerminalCwd();
// SSH: always sync. Its backend starts in utf-8 regardless of
// host.charset, so the push is what keeps the UI state aligned
// across reconnects — including localhost SSH targets, hence
@@ -887,7 +910,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalLogData: captureTerminalLogData,
onOsDetected,
@@ -899,7 +925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
terminalLogDataRef.current = "";
connectionLogBufferRef.current.reset();
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
@@ -941,7 +967,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLineBufferRef,
onTerminalLogData: captureTerminalLogData,
onCwdChange: (cwd: string) => {
terminalCwdTracker.setRendererCwd(cwd);
knownCwdRef.current = cwd;
onTerminalCwdChange?.(sessionId, cwd);
},
onOsc52ReadRequest: handleOsc52ReadRequest,
// Autocomplete integration
@@ -1206,11 +1234,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
@@ -1223,6 +1258,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit({ requireVisible: true });
// Recover the WebGL renderer now that this tab is visible again. Hidden
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
// WebGL context; creating another terminal's context — or the GPU dropping
// a non-composited off-screen canvas — can leave this terminal's drawing
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
// dimensions, becoming visible triggers no resize and therefore no redraw,
// so the corruption persists until the user resizes the window. Force the
// same recovery a resize performs: clear the texture atlas (no-op on the
// DOM renderer) and synchronously repaint every row.
xtermRuntimeRef.current?.clearTextureAtlas();
const visibleTerm = termRef.current;
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
@@ -1536,10 +1583,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (!noAutoRun) data = `${data}\r`;
// Broadcast the exact bytes the active session receives so peers mirror it,
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
// line-by-line on peers, since handleBroadcastInput writes bytes directly
// without re-wrapping. Without broadcasting at all, accepting a snippet in
// broadcast mode would clear peer input (the clear keystrokes already go
// through the broadcast-aware path) but never send the command.
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId);
}
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
@@ -1580,17 +1638,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleOpenSFTP = async () => {
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
const initialPath = await resolveSftpInitialPath();
onOpenSftp(host, initialPath, undefined, sessionId);
return;
}
@@ -1803,17 +1851,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} else {
// Remote terminal: Trigger SFTP upload via parent
if (onOpenSftp) {
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
const initialPath = await resolveSftpInitialPath();
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
}
@@ -2342,29 +2380,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
}
{/* Autocomplete — owns the hook + popup in its own component so
suggestion/selection updates don't re-render Terminal. Mounted
unconditionally; it gates the popup on `visible` internally. */}
<TerminalAutocomplete
termRef={termRef}
sessionId={sessionId}
hostId={host.id}
hostOs={autocompleteHostOs}
settings={autocompleteSettings}
protocol={host.protocol}
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
snippets={snippets}
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
visible={isVisible}
themeColors={effectiveTheme.colors}
containerRef={containerRef}
searchBarOffset={isSearchOpen ? 64 : 30}
keyEventRef={autocompleteKeyEventRef}
inputRef={autocompleteInputRef}
repositionRef={autocompleteRepositionRef}
closeRef={autocompleteCloseRef}
/>
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
@@ -2455,6 +2493,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
/>
</div>
)}
{/* ZMODEM overwrite conflict dialog */}
{zmodem.overwriteRequest && (
<ZmodemOverwriteDialog
filename={zmodem.overwriteRequest.filename}
onRespond={zmodem.respondOverwrite}
/>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

View File

@@ -35,6 +35,8 @@ const baseProps = {
onAddKnownHost: () => {},
onToggleWorkspaceViewMode: () => {},
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
onSplitSession: () => {},
toggleScriptsSidePanelRef: { current: null },
};
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
false,
);
});
test("TerminalLayer re-renders when broadcast state changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, isBroadcastEnabled: () => true } as never,
),
false,
);
});
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, onToggleBroadcast: () => {} } as never,
),
false,
);
});

View File

@@ -57,9 +57,11 @@ import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
import { getTerminalPaneSnapshot, parseTerminalPaneSnapshot } from './terminalPaneVisibility';
import { getScopedTopTabsThemeId } from './terminalTopTabsTheme';
import { resolvePreferredTerminalCwd } from './terminal/sftpCwd';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -464,9 +466,8 @@ interface TerminalLayerProps {
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
}
interface TerminalPaneProps {
@@ -504,6 +505,7 @@ interface TerminalPaneProps {
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onTerminalCwdChange: (sessionId: string, cwd: string | null) => void;
onOpenScripts: () => void;
onOpenTheme: () => void;
onCloseSession: (sessionId: string) => void;
@@ -564,6 +566,7 @@ const terminalPanePropsAreEqual = (
prev.sessionLog === next.sessionLog &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onOpenSftp === next.onOpenSftp &&
prev.onTerminalCwdChange === next.onTerminalCwdChange &&
prev.onOpenScripts === next.onOpenScripts &&
prev.onOpenTheme === next.onOpenTheme &&
prev.onCloseSession === next.onCloseSession &&
@@ -612,6 +615,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
sessionLog,
onHotkeyAction,
onOpenSftp,
onTerminalCwdChange,
onOpenScripts,
onOpenTheme,
onCloseSession,
@@ -727,6 +731,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onOpenSftp={onOpenSftp}
onTerminalCwdChange={onTerminalCwdChange}
onOpenScripts={onOpenScripts}
onOpenTheme={onOpenTheme}
onCloseSession={onCloseSession}
@@ -783,6 +788,7 @@ interface TerminalPanesHostProps {
sessionLog?: { enabled: true; directory: string; format: string };
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onOpenSftp: TerminalPaneProps['onOpenSftp'];
onTerminalCwdChange: TerminalPaneProps['onTerminalCwdChange'];
onOpenScripts: () => void;
onOpenTheme: () => void;
onCloseSession: (sessionId: string) => void;
@@ -884,9 +890,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
toggleScriptsSidePanelRef,
activeSidePanelTabRef,
toggleSidePanelRef,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -894,6 +899,24 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const isVisible = (!isVaultActive && !isSftpActive) || !!draggingSessionId;
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
if (cwd && cwd.trim().length > 0) {
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
}, []);
useEffect(() => {
const liveSessionIds = new Set(sessions.map((session) => session.id));
for (const sessionId of terminalRendererCwdBySessionRef.current.keys()) {
if (!liveSessionIds.has(sessionId)) {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
}
}, [sessions]);
// Stable callback references for Terminal components
const handleCloseSession = useCallback((sessionId: string) => {
@@ -954,10 +977,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(evt);
if (intent.kind === "markDisconnected") {
if (intent.kind === "closeSession") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onUpdateSessionStatus]);
}, [onCloseSession, onUpdateSessionStatus]);
const handleOsDetected = useCallback((hostId: string, distro: string) => {
onUpdateHostDistro(hostId, distro);
@@ -1075,13 +1100,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
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());
useEffect(() => {
sidePanelOpenTabs.forEach((tab, tabId) => {
lastSidePanelTabRef.current.set(tabId, tab);
});
}, [sidePanelOpenTabs]);
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
if (activeSidePanelTabRef) {
activeSidePanelTabRef.current = activeSidePanelTab;
}
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
@@ -1772,13 +1802,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
if (!sessionId) return null;
try {
const result = await terminalBackend.getSessionPwd(sessionId);
return result.success && result.cwd ? result.cwd : null;
} catch {
return null;
}
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
sessionId,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
});
}, [getActiveTerminalSessionId, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
@@ -1821,13 +1849,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
useEffect(() => {
if (!closeSidePanelRef) return;
closeSidePanelRef.current = handleCloseSidePanel;
return () => {
closeSidePanelRef.current = null;
};
}, [closeSidePanelRef, handleCloseSidePanel]);
// 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.
const resolveSftpHostForTab = useCallback((tabId: string): Host | null => {
const stored = sftpHostForTabRef.current.get(tabId);
if (stored) return stored;
const currentWorkspace = activeWorkspaceRef.current;
const currentFocusedSessionId = focusedSessionIdRef.current;
const currentActiveSession = activeSessionRef.current;
const currentSessionHosts = sessionHostsMapRef.current;
if (currentWorkspace && currentFocusedSessionId) {
return currentSessionHosts.get(currentFocusedSessionId) ?? null;
}
if (currentActiveSession) {
return currentSessionHosts.get(currentActiveSession.id) ?? null;
}
return null;
}, []);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
@@ -1840,16 +1878,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
let host: Host | null = null;
const currentWorkspace = activeWorkspaceRef.current;
const currentFocusedSessionId = focusedSessionIdRef.current;
const currentActiveSession = activeSessionRef.current;
const currentSessionHosts = sessionHostsMapRef.current;
if (currentWorkspace && currentFocusedSessionId) {
host = currentSessionHosts.get(currentFocusedSessionId) ?? null;
} else if (currentActiveSession) {
host = currentSessionHosts.get(currentActiveSession.id) ?? null;
}
const host = resolveSftpHostForTab(tabId);
if (!host) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
@@ -1867,7 +1896,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, tab);
return next;
});
}, []);
}, [resolveSftpHostForTab]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
@@ -1907,6 +1936,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
// 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
// available) or scripts.
const handleToggleSidePanel = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const isOpen = sidePanelOpenTabsRef.current.has(tabId);
const sftpAvailable = !!resolveSftpHostForTab(tabId);
const fallbackTab: SidePanelTab = sftpAvailable ? 'sftp' : 'scripts';
const lastTab = lastSidePanelTabRef.current.get(tabId) ?? null;
const intent = resolveSidePanelToggleIntent<SidePanelTab>({ isOpen, lastTab, fallbackTab });
if (intent.kind === 'close') {
handleCloseSidePanel();
return;
}
// 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]);
useEffect(() => {
if (!toggleSidePanelRef) return;
toggleSidePanelRef.current = handleToggleSidePanel;
return () => {
toggleSidePanelRef.current = null;
};
}, [toggleSidePanelRef, handleToggleSidePanel]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
handleSwitchSidePanelTab('theme');
@@ -2220,14 +2277,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost, rawFocusedHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostFontFamilyOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
@@ -2236,14 +2295,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
});
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
@@ -3128,6 +3189,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLog={sessionLogConfig}
onHotkeyAction={onHotkeyAction}
onOpenSftp={handleOpenSftp}
onTerminalCwdChange={handleTerminalCwdChange}
onOpenScripts={handleOpenScripts}
onOpenTheme={handleOpenTheme}
onCloseSession={handleCloseSession}

View File

@@ -2,9 +2,10 @@
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { Check, Wand2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
/** Restrict the list to a single type; omit to show both sections. */
filterType?: 'dark' | 'light';
/** Render an "Auto (match app theme)" entry at the top. */
showAutoOption?: boolean;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const deletedSelectedTheme = useMemo(
() => (selectedThemeId
&& selectedThemeId !== TERMINAL_THEME_AUTO
&& !isUiMatchTerminalThemeId(selectedThemeId)
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
&& !customThemes.some((theme) => theme.id === selectedThemeId)
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
return { darkThemes: dark, lightThemes: light };
}, []);
const visibleCustomThemes = filterType
? customThemes.filter(theme => theme.type === filterType)
: customThemes;
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
return (
<>
{showAutoOption && (
<button
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
)}
>
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
<Wand2 size={14} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
{t('settings.terminal.theme.auto')}
</div>
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
</div>
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
</button>
)}
{hiddenSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
</div>
)}
{/* Dark Themes Section */}
{(!filterType || filterType === 'dark') && (
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Light Themes Section */}
{(!filterType || filterType === 'light') && (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Custom Themes Section */}
{customThemes.length > 0 && (
{visibleCustomThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
{visibleCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}

View File

@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
style={{
backgroundColor: isActive
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-tab-type="session"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
draggable
onDragStart={(e) => onTabDragStart(e, session.id)}
onDragEnd={onTabDragEnd}
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
draggable
onDragStart={(e) => onTabDragStart(e, workspace.id)}
onDragEnd={onTabDragEnd}
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
style={{
backgroundColor: isActive

View File

@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
import type { Host, SSHKey } from "../types.ts";
import { VaultView } from "./VaultView.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const installStorageStub = (sortMode: string | null) => {
const values = new Map<string, string>();
if (sortMode !== null) {
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
},
});
};
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt,
group,
});
const fallbackKey: SSHKey = {
id: "key-1",
label: "Fallback key",
type: "ED25519",
privateKey: "",
source: "generated",
category: "key",
created: 1,
};
const renderVault = (sortMode: string | null, hosts: Host[]) => {
installStorageStub(sortMode);
const noop = () => {};
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onToggleConnectionLogSaved: noop,
onDeleteConnectionLog: noop,
onClearUnsavedConnectionLogs: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
),
);
};
test("Hosts sort mode is restored from storage", () => {
const markup = renderVault("za", [
host("alpha", "Alpha Host", 1),
host("zulu", "Zulu Host", 2),
]);
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts grouped sort mode is restored from storage", () => {
const markup = renderVault("group", [
host("beta", "Beta Host", 1, "Beta Group"),
host("alpha", "Alpha Host", 2, "Alpha Group"),
]);
assert.match(
markup,
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
);
});
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
const markup = renderVault("unknown-sort", [
host("zulu", "Zulu Host", 2),
host("alpha", "Alpha Host", 1),
]);
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
});

View File

@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredString } from "../application/state/useStoredString";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
@@ -121,6 +123,13 @@ type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
const isSortMode = (value: string): value is SortMode =>
value === "az" ||
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -280,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"grid",
);
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const [sortMode, setSortMode] = useState<SortMode>("az");
const [sortMode, setSortMode] = useStoredString<SortMode>(
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
"az",
isSortMode,
);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
splitClaudeEnv,
buildClaudeEnv,
parseEnvLines,
serializeEnvLines,
} from "../settings/tabs/ai/claudeConfigEnv";
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
const result = splitClaudeEnv({
CLAUDE_CONFIG_DIR: "/cfg",
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(result.configDir, "/cfg");
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
});
test("splitClaudeEnv handles undefined env", () => {
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
});
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
assert.deepEqual(
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
);
});
test("serializeEnvLines is the inverse for simple entries", () => {
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
});
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
});
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
const next = buildClaudeEnv(
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
"/cfg",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
);
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});

View File

@@ -143,6 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{

View File

@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
const state = buildManagedAgentState(
[],
'catty',
'claude',
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
assert.deepEqual(state.agents[0].env, {
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
});
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{

View File

@@ -15,6 +15,8 @@ interface ThemeSelectModalProps {
onClose: () => void;
selectedThemeId: string;
onSelect: (themeId: string) => void;
filterType?: 'dark' | 'light';
showAutoOption?: boolean;
}
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
@@ -22,6 +24,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
onClose,
selectedThemeId,
onSelect,
filterType,
showAutoOption,
}) => {
const { t } = useI18n();
@@ -85,6 +89,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
filterType={filterType}
showAutoOption={showAutoOption}
/>
</div>

View File

@@ -48,6 +48,7 @@ import {
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
// ---------------------------------------------------------------------------
// Props
@@ -125,6 +126,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const claudeManagedEnv = useMemo(
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
[externalAgents],
);
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
() => splitClaudeEnv(claudeManagedEnv),
[claudeManagedEnv],
);
const updateClaudeEnv = useCallback(
(nextConfigDir: string, nextEnvText: string) => {
setExternalAgents((prev) =>
prev.map((a) =>
a.id === "discovered_claude"
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
: a,
),
);
},
[setExternalAgents],
);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
@@ -542,6 +566,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
configDir={claudeConfigDir}
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
envText={claudeEnvText}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
/>
</div>

View File

@@ -27,6 +27,7 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../domain/terminalAppearance";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
@@ -315,6 +316,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
setFollowAppTerminalTheme: (value: boolean) => void;
terminalThemeDarkId: string;
setTerminalThemeDarkId: (id: string) => void;
terminalThemeLightId: string;
setTerminalThemeLightId: (id: string) => void;
lightUiThemeId: string;
darkUiThemeId: string;
terminalFontFamilyId: string;
setTerminalFontFamilyId: (id: string) => void;
terminalFontSize: number;
@@ -333,6 +340,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -364,6 +377,7 @@ export default function SettingsTerminalTab(props: {
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
@@ -375,6 +389,38 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
// Preview themes for the follow-app per-mode pickers. resolvedTheme is
// forced per slot so each preview reflects exactly that mode's selection.
const darkPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'dark',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const lightPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'light',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
@@ -556,7 +602,34 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{!followAppTerminalTheme && (
{followAppTerminalTheme ? (
<div className="space-y-2">
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.darkTheme")}
</div>
<ThemePreviewButton
theme={darkPreviewTheme}
onClick={() => setThemeModalSlot('dark')}
buttonLabel={terminalThemeDarkId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.lightTheme")}
</div>
<ThemePreviewButton
theme={lightPreviewTheme}
onClick={() => setThemeModalSlot('light')}
buttonLabel={terminalThemeLightId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
</div>
) : (
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
@@ -570,6 +643,17 @@ export default function SettingsTerminalTab(props: {
selectedThemeId={terminalThemeId}
onSelect={setTerminalThemeId}
/>
<ThemeSelectModal
open={themeModalSlot !== null}
onClose={() => setThemeModalSlot(null)}
selectedThemeId={themeModalSlot === 'dark' ? terminalThemeDarkId : terminalThemeLightId}
onSelect={(id) => {
if (themeModalSlot === 'dark') setTerminalThemeDarkId(id);
else if (themeModalSlot === 'light') setTerminalThemeLightId(id);
}}
filterType={themeModalSlot === 'light' ? 'light' : 'dark'}
showAutoOption
/>
{/* Theme action buttons */}
<div className="flex items-center gap-2 -mt-1">
@@ -810,6 +894,12 @@ export default function SettingsTerminalTab(props: {
>
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.keyboard.optionArrowWordJump")}
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
>
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.accessibility")} />
@@ -894,7 +984,7 @@ export default function SettingsTerminalTab(props: {
label={t("settings.terminal.behavior.forcePromptNewLine")}
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
>
<Toggle checked={terminalSettings.forcePromptNewLine ?? true} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
@@ -990,6 +1080,29 @@ export default function SettingsTerminalTab(props: {
</div>
</div>
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
<div className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground mb-3">
{t("settings.terminal.startupCommandDelay.desc")}
</p>
<div className="space-y-1">
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
<Input
type="number"
min={0}
max={10000}
value={terminalSettings.startupCommandDelayMs}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 10000) {
updateTerminalSetting("startupCommandDelayMs", val);
}
}}
className="w-full"
/>
</div>
</div>
<SectionHeader title={t("settings.terminal.section.keywordHighlight")} />
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">

View File

@@ -1,10 +1,11 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
pathInfo: AgentPathInfo | null;
@@ -12,15 +13,40 @@ export const ClaudeCodeCard: React.FC<{
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
configDir,
onConfigDirChange,
envText,
onEnvTextChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(configDir.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.claude.detecting')
@@ -83,6 +109,53 @@ export const ClaudeCodeCard: React.FC<{
</div>
</div>
) : null}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.claude.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
<input
id="claude-config-dir"
type="text"
value={configDir}
onChange={(e) => onConfigDirChange(e.target.value)}
placeholder={t('ai.claude.configDir.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea
id="claude-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.claude.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
/**
* Pure helpers for the Claude Code card's "config directory + environment
* variables" editor. The managed Claude agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
* is preserved across edits but never shown in the env editor.
*/
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitClaudeEnv(
env: Record<string, string> | undefined,
): { configDir: string; envText: string } {
if (!env) return { configDir: "", envText: "" };
const configDir = env[CONFIG_DIR_KEY] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { configDir, envText: serializeEnvLines(rest) };
}
export function buildClaudeEnv(
prevEnv: Record<string, string> | undefined,
configDir: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
// Preserve discovery-owned key if present.
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
const trimmedDir = String(configDir || "").trim();
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
// Drop managed keys if a user typed them into the free-text editor — the
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -47,11 +47,15 @@ export function buildManagedAgentState(
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const managedEnv = agentKey === "claude"
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
: existingManaged?.env;
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
...(managedEnv ? { env: managedEnv } : {}),
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};

View File

@@ -20,6 +20,7 @@ import React, {
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}

View File

@@ -450,3 +450,32 @@ test("applyKeystroke: ignores non-typing data (escape sequences, control codes)"
restoreDocument();
}
});
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// We believe only "network in" is typed; suggestion is the full command.
addon.show("network interface show", "network in");
assert.equal(addon.isActive(), true);
// The real line shows MORE than we tracked: a bastion host echoed the
// next char ("t") that our client-side buffer never recorded.
const line = "ecOS# network int";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = line.length;
active.getLine = () => ({ translateToString: () => line });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import ReactDOM from "react-dom";
import type { ComponentProps, RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
useTerminalAutocomplete,
AutocompletePopup,
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
type PopupProps = ComponentProps<typeof AutocompletePopup>;
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
type HandlerRef<T> = { current: T | undefined };
interface TerminalAutocompleteProps {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
protocol?: string;
getCwd?: () => string | undefined;
onAcceptText: (text: string) => void;
snippets?: Snippet[];
onAcceptSnippet?: (snippet: Snippet) => void;
/** Whether this terminal tab is the visible one. */
visible: boolean;
themeColors: PopupProps["themeColors"];
containerRef: PopupProps["containerRef"];
searchBarOffset: number;
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
inputRef: HandlerRef<(data: string) => void>;
repositionRef: HandlerRef<() => void>;
closeRef: HandlerRef<() => void>;
}
/**
* Owns the terminal autocomplete hook and renders its popup.
*
* Kept as its own component so the frequent autocomplete state updates
* (suggestions, selection, live-preview navigation) re-render only this small
* subtree rather than the whole Terminal component. The hook's handlers are
* surfaced back to Terminal through refs so the xterm runtime can call them.
*
* Must be mounted unconditionally for the terminal session's lifetime: the hook
* records command history on Enter and intercepts completion keys even while no
* popup is visible. Visibility only gates the rendered popup, not the hook.
*/
export function TerminalAutocomplete({
termRef,
sessionId,
hostId,
hostOs,
settings,
protocol,
getCwd,
onAcceptText,
snippets,
onAcceptSnippet,
visible,
themeColors,
containerRef,
searchBarOffset,
keyEventRef,
inputRef,
repositionRef,
closeRef,
}: TerminalAutocompleteProps) {
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId,
hostOs,
settings,
onAcceptText,
snippets,
onAcceptSnippet,
protocol,
getCwd,
});
// Surface the handlers for runtime wiring. They have stable identities
// (useCallback over refs), so assigning during render is cheap and mirrors
// the wiring Terminal did inline before this was extracted.
keyEventRef.current = autocomplete.handleKeyEvent;
inputRef.current = autocomplete.handleInput;
repositionRef.current = autocomplete.repositionPopup;
closeRef.current = autocomplete.closePopup;
const { state } = autocomplete;
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
return null;
}
// Portal to body so the popup escapes the terminal container's overflow.
return ReactDOM.createPortal(
<AutocompletePopup
suggestions={state.suggestions}
selectedIndex={state.selectedIndex}
position={state.popupPosition}
cursorLineTop={state.popupCursorLineTop}
cursorLineBottom={state.popupCursorLineBottom}
visible={state.popupVisible}
expandUpward={state.expandUpward}
themeColors={themeColors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={state.subDirPanels}
subDirFocusLevel={state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={searchBarOffset}
onDismiss={autocomplete.closePopup}
/>,
document.body,
);
}

View File

@@ -0,0 +1,33 @@
import React, { useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
import { Button } from "../ui/button";
interface Props {
filename: string;
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
}
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
const { t } = useI18n();
const [applyToRest, setApplyToRest] = useState(false);
return (
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground break-all">{filename}</p>
<label className="flex items-center gap-2 text-sm mt-2">
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
{t("zmodem.overwrite.applyToRest")}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -59,6 +59,7 @@ const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
};
/** Lucide icon components for file types in path suggestions */
@@ -91,6 +92,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}></span>
);
/** Small key-cap badge shown on the selected row to hint the actionable key. */
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
height: "16px",
minWidth: "16px",
padding: "0 4px",
fontSize: "11px",
lineHeight: 1,
borderRadius: "4px",
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
flexShrink: 0,
fontFamily:
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
}}
>
{label}
</span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
@@ -327,8 +354,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
{suggestion.displayText}
</span>
{/* Inline description (truncated) */}
{suggestion.description && (
{/* Inline description (truncated). Snippets show only their label
in the row — the full command lives in the detail preview. */}
{suggestion.source !== "snippet" && suggestion.description && (
<span
style={{
fontSize: "11px",
@@ -361,6 +389,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
{isSelected && (
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
)}
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
</span>
)}
</div>
);
})}
@@ -445,7 +483,22 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.description}
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
</div>
)}

View File

@@ -9,6 +9,7 @@
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
/**
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
@@ -112,9 +113,16 @@ export class GhostTextAddon implements IDisposable {
this.disposables.push(
term.onRender(() => {
if (this.isVisible()) {
this.updatePosition();
if (!this.isVisible()) return;
// Fail-safe: if the device echoed input we didn't track (some bastion
// hosts / network OS, #1013), hide rather than draw the ghost over
// already-typed text. Done here (post-echo render) rather than in
// show()/adjustToInput so it never fights the keystroke-time path.
if (this.realLineHasUntrackedInput()) {
this.hide();
return;
}
this.updatePosition();
}),
);
@@ -291,6 +299,23 @@ export class GhostTextAddon implements IDisposable {
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
/**
* True when the real terminal line has more input than we tracked, so
* rendering the ghost would paint over already-typed characters. See
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
* we can't judge (non-ASCII, echo still catching up), so the ghost only
* gets suppressed when corruption is actually imminent.
*/
private realLineHasUntrackedInput(): boolean {
if (!this.term || !this.currentInput) return false;
const buf = this.term.buffer.active;
if (typeof buf?.getLine !== "function") return false;
const line = buf.getLine(buf.baseY + buf.cursorY);
if (!line || typeof line.translateToString !== "function") return false;
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;

View File

@@ -30,9 +30,11 @@ import {
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import type { Snippet } from "../../../domain/models";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet";
export interface CompletionSuggestion {
/** The text to insert */
@@ -49,6 +51,8 @@ export interface CompletionSuggestion {
frequency?: number;
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
/** For snippet suggestions: the source snippet (used by the accept path). */
snippet?: Snippet;
}
export interface CompletionContext {
@@ -168,6 +172,8 @@ export async function getCompletions(
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15 } = options;
@@ -290,6 +296,16 @@ export async function getCompletions(
}
}
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
suggestions.push(snippetSuggestion);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);

View File

@@ -0,0 +1,42 @@
/**
* Fail-safe consistency check for inline (ghost-text) suggestions.
*
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
* cursor, where `trackedInput` is what the client thinks the user has typed.
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
* of sync with what is actually on the terminal line, and the ghost ends up
* painted over characters the user already typed (`int` + ghost `terface` →
* `intterface`).
*
* This detects the one direction that produces visible corruption: the real
* line being AHEAD of the tracked input (it contains the tracked input
* followed by more, untracked characters). SSH echo latency is the opposite
* case — the line is a prefix-behind of the tracked input — and is
* intentionally NOT flagged, so the ghost stays responsive on slow links.
*
* Returns true when the caller should hide the ghost.
*/
export function lineHasUntrackedTrailingInput(
trackedInput: string,
lineBeforeCursor: string,
): boolean {
// Single chars match too loosely to judge reliably; let them through.
if (trackedInput.length < 2) return false;
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
// existing wide-char (CJK / emoji) handling is left untouched.
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
// Use the last occurrence so a prompt or command that repeats the same token
// earlier on the line doesn't shadow the freshly-typed input.
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
if (idx < 0) {
// Tracked input isn't on screen yet — the echo is still catching up
// (latency). Keep the ghost; reality being behind never corrupts.
return false;
}
// Non-whitespace characters between the tracked input and the cursor mean the
// device echoed input we never tracked → the ghost would overlap real text.
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
}

View File

@@ -0,0 +1,24 @@
/**
* Compute the keystrokes to send so the terminal input line becomes exactly
* `candidate`, given what is currently on the line. Drives the popup
* autocomplete live-preview (#1005): moving the selection renders the chosen
* suggestion into the command line, and switching / reverting rewrites it.
*
* - Forward prefix (candidate continues the line): append only the new tail.
* - Otherwise: clear the current input, then write the full candidate. POSIX
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
* sized to the current line length.
*/
export function computeLivePreviewWrite(input: {
currentLine: string;
candidate: string;
os: string;
}): string {
const { currentLine, candidate, os } = input;
if (candidate === currentLine) return "";
if (candidate.startsWith(currentLine)) {
return candidate.slice(currentLine.length);
}
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
return clear + candidate;
}

View File

@@ -20,7 +20,29 @@ const NON_PROMPT_PATTERNS = [
/^:\s*$/, // vim command mode
/^\s*~\s*$/, // vim tilde lines
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
/^SQL>\s/i, // sqlplus SQL> prompts
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
/^pry\([^)]*\)>\s/i,
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
/^lftp\s+\S+>\s/i,
/^\s{3}\.{3}>\s/,
/^cqlsh(?::[\w.-]+)?>\s/i,
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
];
export interface PromptDetectionResult {
@@ -38,30 +60,48 @@ const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
export function isNonPromptLine(lineText: string): boolean {
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
}
function isSpecificShellPromptCandidate(
promptText: string,
options: { allowGreaterThanTerminator?: boolean } = {},
): boolean {
const trimmed = promptText.trim();
if (
!options.allowGreaterThanTerminator &&
(trimmed.endsWith(">") || trimmed.endsWith(""))
) {
return false;
}
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
}
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
const trimmed = promptText.trim();
if (/^root[#%$]$/.test(trimmed)) return true;
if (trimmed.length < 3) return false;
const marker = trimmed[trimmed.length - 1];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
const prev = trimmed[trimmed.length - 2] ?? "";
return /[~:/\\\])]/.test(prev);
}
export interface AlignedPromptResult {
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
prompt: PromptDetectionResult;
/**
* The keystroke buffer, but only when it's both marked reliable AND
* actually matches the tail of the raw detected userInput. Returns
* null otherwise the single signal downstream uses to decide
* whether to record it as the executed command.
* can be validated against the live terminal line. Returns null
* otherwise - the single signal downstream uses to decide whether
* to record it as the executed command.
*/
alignedTyped: string | null;
}
function replacePromptUserInput(
prompt: PromptDetectionResult,
userInput: string,
): PromptDetectionResult {
return {
isAtPrompt: true,
promptText: prompt.promptText,
userInput,
cursorOffset: userInput.length,
};
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
@@ -72,6 +112,499 @@ function getCursorLinePrefix(term: XTerm): string | null {
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
}
function getWrappedCursorPrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line?.isWrapped) return null;
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) return null;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (!promptLine) return null;
let prefix = promptLine.translateToString(false);
for (let row = promptRow + 1; row < cursorY; row++) {
const rowLine = buffer.getLine(row);
if (!rowLine) return null;
prefix += rowLine.translateToString(false);
}
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
}
function inferPromptTextBeforeTypedInput(
cursorPrefix: string,
typedBuffer: string,
allowPartialEcho: boolean,
): string | null {
if (cursorPrefix.endsWith(typedBuffer)) {
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
return promptText.length > 0 ? promptText : null;
}
if (!allowPartialEcho) return null;
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (promptText.length > 0) return promptText;
}
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
for (
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
echoLength >= noSpacePromptMinEchoLength;
echoLength--
) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const hasReliablePartialEcho =
typedBuffer.trim().length <= 2 ||
echoedInput.endsWith(" ") ||
(echoedInput.includes(" ") && echoedInput.length >= 4);
if (!hasReliablePartialEcho) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
}
return null;
}
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
const candidate = promptText.slice(0, promptBoundary).trimEnd();
const finalIndex = candidate.length - 1;
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
for (let i = 0; i < finalIndex; i++) {
const ch = candidate[i];
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
if (nextChar === null || nextChar === " ") continue;
const earlierPrompt = candidate.slice(0, i + 1);
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
if (finalChar !== "$") return true;
}
return false;
}
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
if (promptText.length === 0) return false;
if (rawIsAtPrompt) return true;
const promptBoundary = findPromptBoundary(promptText);
const promptEndsAtBoundary =
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
return (
promptEndsAtBoundary &&
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
isSpecificShellPromptCandidate(promptText)
);
}
function isThemedPromptText(promptText: string): boolean {
for (const ch of promptText) {
if (isPuaChar(ch)) return true;
}
return /[❯❮→➜➤⟩»›]/.test(promptText);
}
function isPromptPathDecoration(trimmed: string): boolean {
return (
trimmed === "~" ||
trimmed.startsWith("~/") ||
trimmed.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
trimmed.includes("\\")
);
}
function isPromptBareDirectoryText(trimmed: string): boolean {
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
return /^[\w.-]+$/.test(trimmed);
}
function isPromptStatusToken(token: string): boolean {
return (
/^git:\([^)]*\)$/.test(token) ||
/^[+$#%>!?*]$/.test(token) ||
token === "✗" ||
token === "✔"
);
}
function isPromptStatusText(trimmed: string): boolean {
const [first = "", ...rest] = trimmed.split(/\s+/);
if (rest.length === 0) return false;
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
return rest.every(isPromptStatusToken);
}
function isPromptStatusDecoration(extra: string): boolean {
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
return isPromptStatusText(extra.trim());
}
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
const trimmed = extra.trim();
if (trimmed.length === 0) return false;
if (!isThemedPromptText(promptText)) return false;
if (startsWithCommonShellCommand(extra)) return false;
if (/^\s*\S+\s+$/.test(extra)) {
return isPromptPathDecoration(trimmed) || (
isPromptBareDirectoryText(trimmed) &&
!startsWithCommonShellCommand(trimmed)
);
}
if (isPromptStatusDecoration(extra)) return true;
for (const ch of extra) {
if (isPuaChar(ch)) return true;
}
return false;
}
function getFinalPromptBoundary(promptText: string): number {
const trimmedEnd = promptText.trimEnd().length;
if (trimmedEnd === 0) return -1;
const markerIndex = trimmedEnd - 1;
const marker = promptText[markerIndex];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
if (nextChar !== null && nextChar !== " ") return -1;
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
}
function endsAtFinalPromptBoundary(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
}
const COMMON_SHELL_COMMANDS = new Set([
"alias",
"awk",
"az",
"brew",
"bun",
"bundle",
"cargo",
"cat",
"cd",
"chmod",
"chown",
"code",
"composer",
"cp",
"curl",
"docker",
"echo",
"emacs",
"env",
"export",
"find",
"gcloud",
"gh",
"git",
"go",
"gradle",
"grep",
"helm",
"java",
"javac",
"kubectl",
"less",
"ls",
"make",
"mkdir",
"mvn",
"mv",
"nano",
"node",
"npm",
"npx",
"nvim",
"php",
"pip",
"pip3",
"pnpm",
"printf",
"python",
"python3",
"rails",
"rm",
"rsync",
"ruby",
"rustc",
"scp",
"screen",
"sed",
"ssh",
"sudo",
"tail",
"tar",
"terraform",
"tmux",
"touch",
"uv",
"vi",
"vim",
"yarn",
]);
function getLeadingShellCommandWord(text: string): string | null {
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
}
function startsWithCommonShellCommand(text: string): boolean {
const command = getLeadingShellCommandWord(text);
return command !== null && COMMON_SHELL_COMMANDS.has(command);
}
function isCompleteSpecificPrompt(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return (
promptBoundary >= 0 &&
promptText.slice(promptBoundary).trim().length === 0 &&
isSpecificShellPromptCandidate(promptText) &&
!isEmbeddedPromptMarker(promptText, promptBoundary)
);
}
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
}
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
const trimmedStart = text.trimStart();
if (startsWithCommonShellCommand(trimmedStart)) return true;
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
return startsWithCommonShellCommand(afterDecoration);
}
function isSingleBareDirectoryExtra(extra: string): boolean {
const trimmed = extra.trim();
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
}
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
return /^\s+\S+\s+$/.test(extra);
}
type PromptDecorationReconcileOptions = {
allowSingleWordCommandDirectory?: boolean;
};
function canTreatCommonCommandNameAsThemedDirectory(
extra: string,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
const trimmedInput = typedInput.trim();
return (
isSingleBareDirectoryExtra(extra) &&
(
/\s/.test(trimmedInput) ||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
(
options.allowSingleWordCommandDirectory === true &&
hasExplicitThemedDirectorySpacing(extra)
)
)
);
}
function canReconcilePromptDecoration(
prompt: PromptDetectionResult,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
if (
!prompt.isAtPrompt ||
!typedInput ||
prompt.userInput.length <= typedInput.length ||
!prompt.userInput.endsWith(typedInput)
) {
return false;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
if (
isThemedPromptText(prompt.promptText) &&
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
) {
return true;
}
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
return false;
}
const candidatePromptText = prompt.promptText + extra;
const promptEndsAtBoundary =
endsAtFinalPromptBoundary(candidatePromptText) &&
isSpecificShellPromptCandidate(candidatePromptText);
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
}
function alignTypedInputFromCursorPrefix(
raw: PromptDetectionResult,
cursorPrefix: string | null,
typedBuffer: string,
): AlignedPromptResult | null {
if (!cursorPrefix) return null;
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
return null;
}
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
function canUseReliablePromptPrefix(
raw: PromptDetectionResult,
typedBuffer: string,
): boolean {
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
return false;
}
if (typedBuffer.length <= raw.userInput.length) return false;
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
allowShortEcho: allowsShortPromptEcho(raw.promptText),
});
}
function isLikelyBareMongoPromptName(promptName: string): boolean {
return /^(?:test|admin|local|config)$/i.test(promptName);
}
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const promptName = trimmed.slice(0, -1).trim();
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
}
function endsWithStandardShellPrompt(promptText: string): boolean {
const finalChar = promptText.trimEnd().at(-1);
return finalChar === "$" || finalChar === "#" || finalChar === "%";
}
function allowsShortPromptEcho(promptText: string): boolean {
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
}
function isReliableTypedPrefix(
echoedInput: string,
typedBuffer: string,
options: { allowShortEcho?: boolean } = {},
): boolean {
if (!typedBuffer.startsWith(echoedInput)) return false;
if (
options.allowShortEcho &&
typedBuffer.trim().length <= 2 &&
echoedInput.trim().length >= 1
) {
return true;
}
return (
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
(echoedInput.includes(" ") && echoedInput.length >= 4)
);
}
function withTypedUserInput(
prompt: PromptDetectionResult,
typedBuffer: string,
): PromptDetectionResult {
return {
...prompt,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
};
}
function alignThemedDecorationWithPartialEcho(
raw: PromptDetectionResult,
typedBuffer: string,
): AlignedPromptResult | null {
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!raw.userInput.endsWith(echoedInput)) continue;
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
if (extra.length === 0) continue;
const hasReliableThemedDirectoryPrefix =
isSingleBareDirectoryExtra(extra) &&
hasExplicitThemedDirectorySpacing(extra) &&
typedBuffer.trim().length <= 3 &&
echoedInput.trim().length >= 1;
const syntheticPrompt = {
...raw,
userInput: extra + typedBuffer,
cursorOffset: extra.length + typedBuffer.length,
};
if (
!hasReliableThemedDirectoryPrefix &&
!isReliableTypedPrefix(echoedInput, typedBuffer)
) {
continue;
}
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
allowSingleWordCommandDirectory: true,
})) continue;
return {
prompt: {
isAtPrompt: true,
promptText: raw.promptText + extra,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
return null;
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
@@ -88,15 +621,26 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
const lineText = line.translateToString(false);
// Check for non-prompt patterns (pagers, editors, etc.)
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return NO_PROMPT;
if (isNonPromptLine(lineText)) return NO_PROMPT;
if (line.isWrapped) {
const wrappedPrefix = getWrappedCursorPrefix(term);
if (wrappedPrefix && isNonPromptLine(wrappedPrefix)) return NO_PROMPT;
}
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
// Try to find the prompt boundary on the current line
const promptEnd = findPromptBoundary(lineText);
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
// Try to find the prompt boundary on the current line. xterm buffer rows are
// padded with blank cells; when the cursor is at the visible row end, scan
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
// trailing space. If there is command text to the right of the cursor, keep
// the full line so "$" / ">" inside mid-line edits are validated against
// their real following character.
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Use cursor position to determine actual input length — don't trim trailing
@@ -125,6 +669,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
const promptLine = buffer.getLine(promptRow);
if (promptLine) {
const promptLineText = promptLine.translateToString(false);
if (isNonPromptLine(promptLineText)) return NO_PROMPT;
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
const promptText = promptLineText.substring(0, pEnd);
@@ -139,6 +684,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
const cursorOffset = userInput.length;
if (isNonPromptLine(promptText + userInput)) return NO_PROMPT;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
@@ -165,6 +711,56 @@ function isPuaChar(ch: string): boolean {
return code >= 0xE000 && code <= 0xF8FF;
}
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
if (boundary <= 0) return -1;
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
}
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
if (markerIndex <= 0) return false;
const marker = lineText[markerIndex];
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
const prev = lineText[markerIndex - 1];
return !/[\s~:\])}]/.test(prev);
}
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
}
function canSupersedeThemedPromptBoundary(
lineText: string,
previousBoundary: number,
markerIndex: number,
): boolean {
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
const rawBetween = lineText.slice(previousBoundary, markerIndex);
const between = rawBetween.trim();
return (
between.length === 0 ||
isPromptPathDecoration(between) ||
isPromptStatusText(between) ||
(
/^\s/.test(rawBetween) &&
isPromptBareDirectoryText(between)
)
);
}
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "";
}
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
if (markerIndex <= 0) return false;
if (lineText[markerIndex - 1] !== " ") return false;
return lineText[markerIndex + 1] === " ";
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
@@ -193,6 +789,15 @@ function findPromptBoundary(lineText: string): number {
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
if (
(ch === ">" || ch === "") &&
lastStandardBoundary >= 0 &&
/\s/.test(lineText.slice(0, i).trim()) &&
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
) {
continue;
}
// Must be followed by a space or end-of-line.
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
@@ -252,6 +857,31 @@ function findPromptBoundary(lineText: string): number {
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
const candidatePromptText = lineText.slice(0, boundary);
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
continue;
}
if (isStandard && lastStandardBoundary >= 0) {
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
lineText,
lastStandardBoundary,
getBoundaryMarkerIndex(lineText, boundary),
);
const canSupersedePreviousBoundary =
canPromptMarkerSupersedePreviousBoundary(ch) &&
(
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
themedPromptCanSupersede
) &&
(
themedPromptCanSupersede ||
isSpecificShellPromptCandidate(candidatePromptText, {
allowGreaterThanTerminator: ch === ">" || ch === "",
})
);
if (!canSupersedePreviousBoundary) continue;
}
if (isStandard) {
lastStandardBoundary = boundary;
} else {
@@ -291,6 +921,11 @@ export function reconcilePromptWithTypedInput(
prompt.userInput.length > typedInput.length &&
prompt.userInput.endsWith(typedInput)
) {
if (!canReconcilePromptDecoration(prompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return prompt;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
return {
isAtPrompt: true,
@@ -302,6 +937,36 @@ export function reconcilePromptWithTypedInput(
return prompt;
}
export function reconcilePromptWithExternalCommand(
prompt: PromptDetectionResult,
command: string,
): PromptDetectionResult | null {
const typedInput = command.trim();
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
const syntheticPrompt = {
...prompt,
userInput: `${prompt.userInput}${typedInput}`,
cursorOffset: prompt.userInput.length + typedInput.length,
};
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return null;
}
const extra = syntheticPrompt.userInput.slice(
0,
syntheticPrompt.userInput.length - typedInput.length,
);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
/**
* Unified entry point for any autocomplete code path that needs a prompt
* view. Every consumer (fetchSuggestions, insertSuggestion,
@@ -312,13 +977,11 @@ export function reconcilePromptWithTypedInput(
* pre-#806 behavior, not a worse pollution.
*
* Alignment rule: the keystroke buffer is usable only when it's marked
* reliable AND the raw detected prompt still looks like the same shell
* line. When the raw buffer has either over-captured prompt chrome
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
* shell echo/render is lagging behind local keystrokes
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
* Otherwise the buffer is ignored and the raw detector result passes
* through.
* reliable and it can be reconciled with the live line. Exact raw
* matches are safe, over-captured prompt chrome can be moved back into
* promptText, and no-space prompts can be inferred from the cursor line
* when the inferred prompt still looks like a shell prompt. Otherwise
* the buffer is ignored and the raw detector result passes through.
*/
export function getAlignedPrompt(
term: XTerm | null,
@@ -327,38 +990,40 @@ export function getAlignedPrompt(
): AlignedPromptResult {
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
const raw = detectPrompt(term);
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
if (!typedReliable || typedBuffer.length === 0) {
return { prompt: raw, alignedTyped: null };
}
if (raw.userInput === typedBuffer) {
return { prompt: raw, alignedTyped: typedBuffer };
}
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
return {
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
return {
prompt: replacePromptUserInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
const cursorLinePrefix = getCursorLinePrefix(term);
if (cursorLinePrefix?.endsWith(typedBuffer)) {
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
if (promptText.length > 0) {
if (raw.isAtPrompt) {
if (raw.userInput === typedBuffer) {
return { prompt: raw, alignedTyped: typedBuffer };
}
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
if (prompt === raw) return { prompt: raw, alignedTyped: null };
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
prompt,
alignedTyped: typedBuffer,
};
}
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
if (themedDecorationAlignment) return themedDecorationAlignment;
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
}
const cursorPrefixCandidates = [
getWrappedCursorPrefix(term),
getCursorLinePrefix(term),
];
for (const cursorPrefix of cursorPrefixCandidates) {
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
if (aligned) return aligned;
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -0,0 +1,49 @@
/**
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Each suggestion carries the full Snippet so the
* accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function appliesToHost(snippet: Snippet, hostId?: string): boolean {
if (!snippet.targets || snippet.targets.length === 0) return true;
return hostId !== undefined && snippet.targets.includes(hostId);
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!appliesToHost(snippet, options.hostId)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
if (!matches) continue;
out.push({
text: snippet.label,
displayText: snippet.label,
description: snippet.command,
source: "snippet",
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
snippet,
});
}
out.sort((a, b) => b.score - a.score);
return out;
}

View File

@@ -11,14 +11,21 @@
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import { GhostTextAddon } from "./GhostTextAddon";
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
import {
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import type { Snippet } from "../../../domain/models";
import { recordCommand } from "./commandHistoryStore";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
import { getXTermCellDimensions } from "./xtermUtils";
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { computeLivePreviewWrite } from "./livePreviewSequence";
export interface AutocompleteSettings {
enabled: boolean;
@@ -41,6 +48,18 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
fastTypingThresholdMs: 40,
};
/**
* Whether completion work is worth doing — i.e. whether anything would
* actually be rendered. With both the popup and ghost text disabled, querying
* completions only to discard the result is pure main-thread waste, so callers
* skip it entirely.
*/
export function shouldQueryCompletions(
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
): boolean {
return settings.showPopupMenu || settings.showGhostText;
}
/** Shared empty state to avoid creating new objects on every reset */
const EMPTY_STATE: AutocompleteState = Object.freeze({
suggestions: [],
@@ -94,6 +113,10 @@ interface UseTerminalAutocompleteOptions {
protocol?: string;
/** Get current working directory (from OSC 7 or other source) */
getCwd?: () => string | undefined;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Accept a snippet — clears typed input then runs it (host-canonical send) */
onAcceptSnippet?: (snippet: Snippet) => void;
}
export interface TerminalAutocompleteHandle {
@@ -107,10 +130,100 @@ export interface TerminalAutocompleteHandle {
dispose: () => void;
}
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
function hasStandardShellPromptTerminator(promptText: string): boolean {
return /[$#%>]$/.test(promptText.trimEnd());
}
function isSingleThemedPromptTerminator(promptText: string): boolean {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
}
function isThemedPromptPathToken(token: string): boolean {
return (
token === "~" ||
token.startsWith("~/") ||
token.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(token) ||
token.includes("\\")
);
}
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
const hasThemedPromptMarker =
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
Array.from(prompt.promptText).some((ch) => {
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
});
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
return false;
}
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
return (
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
/\S+\s+\S/.test(prompt.userInput)
);
}
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
}
export function getCommandToRecordOnEnter(
livePrompt: PromptDetectionResult,
alignedTyped: string | null,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
if (reliableTypedCommand) {
const reconciledPrompt = reconcilePromptWithExternalCommand(
livePrompt,
reliableTypedCommand,
);
if (reconciledPrompt) return reliableTypedCommand;
}
const liveCommand = livePrompt.userInput.trim();
if (!liveCommand && reliableTypedCommand) {
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
? null
: reliableTypedCommand;
}
if (!liveCommand) return null;
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
const liveInputMayIncludePromptDecoration =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
liveCommand !== typedBuffer.trim() &&
liveCommand.endsWith(typedBuffer.trim());
if (liveInputMayIncludePromptDecoration) return null;
const liveInputMayBeLagging =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
typedBuffer.length > livePrompt.userInput.length &&
typedBuffer.startsWith(livePrompt.userInput);
if (liveInputMayBeLagging) return null;
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
return liveCommand;
}
export function useTerminalAutocomplete(
options: UseTerminalAutocompleteOptions,
): TerminalAutocompleteHandle {
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd } = options;
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd, snippets, onAcceptSnippet } = options;
const rawSettings: AutocompleteSettings = {
...DEFAULT_AUTOCOMPLETE_SETTINGS,
...userSettings,
@@ -132,6 +245,10 @@ export function useTerminalAutocomplete(
settingsRef.current = settings;
const onAcceptTextRef = useRef(onAcceptText);
onAcceptTextRef.current = onAcceptText;
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
const onAcceptSnippetRef = useRef(onAcceptSnippet);
onAcceptSnippetRef.current = onAcceptSnippet;
const hostIdRef = useRef(hostId);
hostIdRef.current = hostId;
const hostOsRef = useRef(hostOs);
@@ -158,6 +275,10 @@ export function useTerminalAutocomplete(
const fetchVersionRef = useRef(0);
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
const lastAcceptedCommandRef = useRef<string | null>(null);
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
const previewBaselineRef = useRef<string>("");
/** Whether a popup candidate is currently rendered into the command line (#1005). */
const previewActiveRef = useRef(false);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
@@ -441,6 +562,41 @@ export function useTerminalAutocomplete(
});
}, [termRef]);
/**
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
*/
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const panel = s.subDirPanels[level];
if (!panel) return;
const { prompt } = getAlignedPrompt(
term, typedInputBufferRef.current, typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const parsed = parseCommandLine(prompt.userInput);
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
+ (parsed.wordIndex > 0 ? " " : "");
const currentToken = parsed.currentWord;
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
? currentToken[0] : "";
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
const suffix = entry.type === "directory" ? "/" : "";
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name : shellEscape(entry.name);
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
previewActiveRef.current = true;
lastAcceptedCommandRef.current = newCommand;
}, [termRef, writeToTerminal]);
/** Handle selecting a file/directory from any sub-dir panel.
* Builds the full path from the panel stack and replaces the current input. */
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
@@ -505,6 +661,15 @@ export function useTerminalAutocomplete(
return;
}
// Nothing will be rendered when both the popup and ghost text are off, so
// don't run the (potentially expensive) completion query just to throw the
// result away. Clear any stale state and bail before touching history,
// fig specs, or remote path lookups.
if (!shouldQueryCompletions(settingsRef.current)) {
clearState();
return;
}
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
@@ -543,6 +708,7 @@ export function useTerminalAutocomplete(
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
cwd,
snippets: snippetsRef.current,
});
if (disposedRef.current || version !== fetchVersionRef.current) return;
@@ -560,7 +726,8 @@ export function useTerminalAutocomplete(
if (settingsRef.current.showGhostText) {
const ghost = ghostAddonRef.current;
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
// Snippets are popup-only — never used as inline ghost text.
const nextSuggestion = completions.find((c) => c.source !== "snippet")?.text ?? null;
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
if (ghostDecision.type === "show") {
ghost?.show(ghostDecision.suggestion, input);
@@ -571,6 +738,9 @@ export function useTerminalAutocomplete(
// Popup
if (settingsRef.current.showPopupMenu && completions.length > 0) {
// Live-preview baseline: the typed input these suggestions completed.
previewBaselineRef.current = input;
previewActiveRef.current = false;
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
startTransition(() => {
setState((prev) => {
@@ -643,29 +813,21 @@ export function useTerminalAutocomplete(
// Require a live prompt before trusting either keystroke buffer
// or buffer-based detection — otherwise sudo password Enter
// would record the typed password as a command.
const typedBuffer = typedInputBufferRef.current;
const typedBufferReliable = typedBufferReliableRef.current;
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
termRef.current,
typedInputBufferRef.current,
typedBufferReliableRef.current,
typedBuffer,
typedBufferReliable,
);
if (livePrompt.isAtPrompt) {
// alignedTyped is only non-null when the buffer is reliable
// AND matches the live line's tail — that single signal
// covers both the robbyrussell "~ " case (#806) and the
// stale-buffer cases from out-of-band pastes / history
// recall (#814 P1/P2). When it's null we fall back to the
// reconciled livePrompt.userInput, which for paste-bypass
// scenarios lands on pre-PR behavior (no regression).
if (alignedTyped && alignedTyped.trim()) {
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
} else if (livePrompt.userInput.trim()) {
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
}
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
// Only fall back to the cached prompt when we have no live
// reading at all — guards against recording during interactive
// prompts where detectPrompt correctly bails out.
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
const commandToRecord = getCommandToRecordOnEnter(
livePrompt,
alignedTyped,
typedBuffer,
typedBufferReliable,
);
if (commandToRecord) {
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
}
}
lastAcceptedCommandRef.current = null;
@@ -789,6 +951,10 @@ export function useTerminalAutocomplete(
// User is typing more — invalidate accepted command fallback since the
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// The previewed candidate is now edited, so the line is the user's own
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
@@ -968,10 +1134,11 @@ export function useTerminalAutocomplete(
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
e.preventDefault();
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
if (selected) insertSuggestion(selected, false);
return false;
// #1005: don't intercept Tab. Keep whatever is currently rendered on
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
@@ -1000,8 +1167,10 @@ export function useTerminalAutocomplete(
panels[focusLevel] = { ...p, selectedIndex: newIdx };
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
});
// Auto-expand next level if the newly selected item is a directory
// Live-render the highlighted entry's full path into the line (#1005).
const newEntry = focusedPanel.entries[newIdx];
if (newEntry) renderSubDirPath(focusLevel, newEntry);
// Auto-expand next level if the newly selected item is a directory
if (newEntry?.type === "directory") {
expandSubDir(focusLevel, newEntry);
}
@@ -1057,39 +1226,44 @@ export function useTerminalAutocomplete(
return true;
}
// Main panel navigation
if (e.key === "ArrowUp") {
// Main panel navigation. The cycle includes a -1 "no selection" slot so
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
// the selection live-renders the candidate into the command line (#1005).
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const n = s.suggestions.length;
const cur = s.selectedIndex;
const next =
e.key === "ArrowDown"
? (cur >= n - 1 ? -1 : cur + 1)
: (cur <= -1 ? n - 1 : cur - 1);
setState((prev) => ({
...prev,
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
selectedIndex: next,
subDirPanels: [], subDirFocusLevel: -1,
}));
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setState((prev) => ({
...prev,
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
subDirPanels: [], subDirFocusLevel: -1,
}));
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
return false;
}
// Enter on popup
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (e.key === "Enter") {
if (s.selectedIndex >= 0) {
const selected = s.suggestions[s.selectedIndex];
if (selected) {
e.preventDefault();
insertSuggestion(selected, true);
return false;
}
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
if (selected?.source === "snippet" && selected.snippet) {
e.preventDefault();
previewActiveRef.current = false;
acceptSnippet(selected.snippet);
return false; // consume — run the snippet, not the typed text
}
clearState();
previewActiveRef.current = false;
return true;
}
}
@@ -1098,8 +1272,12 @@ export function useTerminalAutocomplete(
// when only ghost text is showing (ghost text is passive/non-intrusive)
if (e.key === "Escape" && s.popupVisible) {
e.preventDefault();
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
ghost?.hide();
clearState();
previewActiveRef.current = false;
return false;
}
@@ -1109,6 +1287,59 @@ export function useTerminalAutocomplete(
[writeToTerminal],
);
/**
* Render the suggestion at `index` straight into the command line (Termius
* live-preview, #1005). `index < 0` restores the user's typed baseline.
*/
const renderPreviewSelection = useCallback((index: number) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const baseline = previewBaselineRef.current;
const selected = index >= 0 ? s.suggestions[index] : null;
// Snippets aren't literal completions — keep the user's typed text in the
// line (the popup detail panel shows the full command instead).
const candidate =
selected && selected.source !== "snippet" ? selected.text : baseline;
const { prompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput,
candidate,
os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = candidate;
typedBufferReliableRef.current = true;
const isPreview = index >= 0 && candidate !== baseline;
previewActiveRef.current = isPreview;
lastAcceptedCommandRef.current = isPreview ? candidate : null;
}, [termRef, writeToTerminal]);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
const acceptSnippet = useCallback((snippet: Snippet) => {
const term = termRef.current;
if (term) {
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (prompt.isAtPrompt && prompt.userInput.length > 0) {
const clearSequence = hostOsRef.current === "windows"
? "\b".repeat(prompt.userInput.length)
: "\x15"; // Ctrl+U (readline kill-line)
writeToTerminal(clearSequence);
}
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
onAcceptSnippetRef.current?.(snippet);
clearState();
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
}, [termRef, writeToTerminal]);
/**
* Insert a suggestion into the terminal.
* @param execute If true, also sends \r to execute the command.
@@ -1181,9 +1412,13 @@ export function useTerminalAutocomplete(
*/
const selectSuggestion = useCallback(
(suggestion: CompletionSuggestion) => {
if (suggestion.source === "snippet" && suggestion.snippet) {
acceptSnippet(suggestion.snippet);
return;
}
insertSuggestion(suggestion, false);
},
[insertSuggestion],
[insertSuggestion, acceptSnippet],
);
const closePopup = useCallback(() => {

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getCompletions } from "./autocomplete/completionEngine";
import type { Snippet } from "../../domain/models";
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
test("getCompletions includes snippet suggestions at the command position", async () => {
const out = await getCompletions("dep", { snippets: [deploySnippet] });
const snip = out.find((s) => s.source === "snippet");
assert.ok(snip, "expected a snippet suggestion");
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions does not surface snippets past the command position", async () => {
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
test("queries completions when the popup menu is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
true,
);
});
test("queries completions when ghost text is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
true,
);
});
test("skips completion work when both popup and ghost text are off", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
false,
);
});

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
test("concatenates appended chunks while under the cap", () => {
const buf = createConnectionLogBuffer(100);
buf.append("foo");
buf.append("bar");
buf.append("baz");
assert.equal(buf.toString(), "foobarbaz");
});
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
const max = 10;
const buf = createConnectionLogBuffer(max);
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-max));
assert.equal(buf.toString().length, max);
});
test("trims a single chunk larger than the cap to its last maxChars", () => {
const buf = createConnectionLogBuffer(5);
buf.append("0123456789");
assert.equal(buf.toString(), "56789");
});
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
const buf = createConnectionLogBuffer(6);
buf.append("abcde"); // 5
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
assert.equal(buf.toString(), "efghij");
});
test("stays correct across many small appends (ring semantics)", () => {
const max = 50;
const buf = createConnectionLogBuffer(max);
let naive = "";
for (let i = 0; i < 500; i++) {
const chunk = `x${i}-`;
buf.append(chunk);
naive += chunk;
}
assert.equal(buf.toString(), naive.slice(-max));
});
test("reset clears the buffer", () => {
const buf = createConnectionLogBuffer(100);
buf.append("hello");
buf.reset();
assert.equal(buf.toString(), "");
buf.append("world");
assert.equal(buf.toString(), "world");
});
test("ignores empty appends", () => {
const buf = createConnectionLogBuffer(100);
buf.append("a");
buf.append("");
buf.append("b");
assert.equal(buf.toString(), "ab");
});
test("keeps the segment count bounded across many tiny appends", () => {
// The whole point of the rewrite: trimming must not walk one array entry
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
// single-char appends arrive once it's at capacity.
const maxChars = 100;
const blockSize = 10;
const buf = createConnectionLogBuffer(maxChars, blockSize);
let naive = "";
for (let i = 0; i < 10000; i++) {
buf.append("x");
naive += "x";
}
assert.ok(
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
`segmentCount ${buf.segmentCount()} exceeded the bound`,
);
assert.equal(buf.toString(), naive.slice(-maxChars));
});
test("seals and trims whole blocks with a small blockSize", () => {
const buf = createConnectionLogBuffer(10, 4);
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
});

View File

@@ -0,0 +1,94 @@
/**
* A bounded, append-only text buffer that retains only the last `maxChars`
* characters — the connection log used for diagnostics/replay.
*
* The naive implementation (`log += chunk; if (log.length > max) log =
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
* cap is reached — on the render thread, for every output chunk including each
* echoed keystroke.
*
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
* the oldest data therefore only ever drops/slices a handful of blocks — never
* one array element per append, which would make trim O(number of appends) and
* defeat the purpose. Append is amortized O(chunk); the full string is
* materialized only on `toString()` (called rarely, on finalize).
*/
export interface ConnectionLogBuffer {
append(chunk: string): void;
toString(): string;
reset(): void;
/**
* Number of internal string segments currently retained. Exposed for tests
* to assert the bounded-memory / bounded-trim property.
*/
segmentCount(): number;
}
const DEFAULT_BLOCK_SIZE = 64 * 1024;
export function createConnectionLogBuffer(
maxChars: number,
blockSize: number = DEFAULT_BLOCK_SIZE,
): ConnectionLogBuffer {
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
let tail = ""; // open block currently being filled (newest data)
let total = 0; // total retained length across blocks + tail
const trim = () => {
let overflow = total - maxChars;
if (overflow <= 0) return;
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
while (overflow > 0 && blocks.length > 0) {
const head = blocks[0];
if (head.length <= overflow) {
blocks.shift();
total -= head.length;
overflow -= head.length;
} else {
blocks[0] = head.slice(overflow);
total -= overflow;
overflow = 0;
}
}
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
// maxChars); keep its last `maxChars` characters.
if (overflow > 0) {
tail = tail.slice(overflow);
total -= overflow;
}
};
return {
append(chunk: string): void {
if (!chunk) return;
// A single chunk at/over the cap can only contribute its own tail.
if (chunk.length >= maxChars) {
blocks = [];
tail = chunk.slice(chunk.length - maxChars);
total = tail.length;
return;
}
tail += chunk;
total += chunk.length;
if (tail.length >= blockSize) {
blocks.push(tail);
tail = "";
}
if (total > maxChars) trim();
},
toString(): string {
return blocks.length > 0 ? blocks.join("") + tail : tail;
},
reset(): void {
blocks = [];
tail = "";
total = 0;
},
segmentCount(): number {
return blocks.length + (tail.length > 0 ? 1 : 0);
},
};
}

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
});
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
// Tracked is one char behind what the device actually shows.
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
});
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
// The tracked input hasn't been fully echoed yet — reality being behind
// never corrupts, so the ghost must stay.
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
});
test("ignores trailing whitespace after the tracked input", () => {
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
});
test("hides when untracked non-space input follows the tracked input", () => {
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
});
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
// Prompt contains 'int'; the real typed 'int' is the one at the end.
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
});
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
});
test("skips single-character input", () => {
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
});
test("returns false when the tracked input isn't on the line yet (latency)", () => {
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
});

View File

@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
}
});
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeOverwrite?.();
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
setOverwriteRequest(null);
};
}, [sessionId]);
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
return { ...state, cancel };
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
setOverwriteRequest((req) => {
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
return null;
});
}, []);
return { ...state, cancel, overwriteRequest, respondOverwrite };
}

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
test("appends only the tail when the candidate continues the current line", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
"cker",
);
});
test("returns empty when the line already equals the candidate", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
"",
);
});
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
"\x15df",
);
});
test("clears when switching to a shorter prefix candidate", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
"\x15docker",
);
});
test("reverting to the typed baseline clears then rewrites the baseline", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
"\x15do",
);
});
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
"\b\b\bxy",
);
});

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminalAltKeyOptions } from "./altKeyOptions";
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
// flag was read from settings but only ever wired to the mouse alt-click
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
assert.deepEqual(terminalAltKeyOptions(true), {
macOptionIsMeta: true,
altClickMovesCursor: false,
});
});
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
assert.deepEqual(terminalAltKeyOptions(false), {
macOptionIsMeta: false,
altClickMovesCursor: true,
});
});

View File

@@ -0,0 +1,20 @@
export interface TerminalAltKeyOptions {
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
macOptionIsMeta: boolean;
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
altClickMovesCursor: boolean;
}
/**
* Map the user's "Use Option as Meta key" setting to xterm.js options.
*
* Kept in one place so terminal init (createXTermRuntime) and the live settings
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
*/
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
return {
macOptionIsMeta: altAsMeta,
altClickMovesCursor: !altAsMeta,
};
}

View File

@@ -1,7 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
import {
createTerminalSessionStarters,
getMissingChainHostIds,
splitStartupCommandLines,
normalizeStartupCommandDelay,
} from "./createTerminalSessionStarters";
import { createPromptLineBreakState } from "./promptLineBreak";
import { pasteTextIntoTerminal } from "./terminalUserPaste";
@@ -2279,6 +2284,110 @@ test("startTelnet waits for auto-login before running the startup command", asyn
assert.equal(disposedAutoLoginCancelListener, true);
});
test("startTelnet runs a multi-line startup command in sequence", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let capturedOptions: Record<string, unknown> | null = null;
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let disposedAutoLoginCancelListener = false;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onTelnetAutoLoginComplete: (sessionId: string, cb: (evt: { sessionId: string }) => void) => {
assert.equal(sessionId, "session-1");
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => () => {
disposedAutoLoginCancelListener = true;
},
onChainProgress: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "ssh-user",
telnetUsername: "telnet-user",
telnetPassword: "",
telnetPort: 2323,
startupCommand: "first cmd\nsecond cmd",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: { startupCommandDelayMs: 20 },
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
onCommandExecuted: (command: string) => {
executedCommands.push(command);
},
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.ok(capturedOptions);
assert.ok(autoLoginComplete);
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(writtenCommands, []);
assert.deepEqual(executedCommands, []);
autoLoginComplete({ sessionId: "session-1" });
// Wait long enough for both lines (delay before first + delay between).
await new Promise((resolve) => setTimeout(resolve, 200));
assert.deepEqual(writtenCommands, ["first cmd\r", "second cmd\r"]);
assert.deepEqual(executedCommands, ["first cmd", "second cmd"]);
assert.equal(disposedAutoLoginCancelListener, true);
});
test("startTelnet cancels pending startup command when user takes over", async () => {
const writtenCommands: string[] = [];
let capturedOptions: Record<string, unknown> | null = null;
@@ -2623,3 +2732,25 @@ test("startTelnet rejects configured proxies instead of connecting directly", as
assert.equal(started, false);
assert.match(error, /Telnet does not support proxy/);
});
test("splitStartupCommandLines drops blank lines but keeps content verbatim", () => {
assert.deepEqual(splitStartupCommandLines("sudo -i\nalias dc=\"docker compose\""), [
"sudo -i",
'alias dc="docker compose"',
]);
// Single-line content is preserved verbatim (leading/trailing spaces kept).
assert.deepEqual(splitStartupCommandLines(" cd /app "), [" cd /app "]);
assert.deepEqual(splitStartupCommandLines("a\n\n \nb"), ["a", "b"]);
assert.deepEqual(splitStartupCommandLines("echo hi\r\nwhoami"), ["echo hi", "whoami"]);
assert.deepEqual(splitStartupCommandLines(""), []);
assert.deepEqual(splitStartupCommandLines(" "), []);
});
test("normalizeStartupCommandDelay defaults and clamps", () => {
assert.equal(normalizeStartupCommandDelay(undefined), 600);
assert.equal(normalizeStartupCommandDelay(Number.NaN), 600);
assert.equal(normalizeStartupCommandDelay(0), 0);
assert.equal(normalizeStartupCommandDelay(1500), 1500);
assert.equal(normalizeStartupCommandDelay(-50), 0);
assert.equal(normalizeStartupCommandDelay(999999), 10000);
});

View File

@@ -27,6 +27,7 @@ import {
syncPromptLineBreakState,
type PromptLineBreakState,
} from "./promptLineBreak";
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
};
export type PendingAuth = {
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
}
};
// Output back-pressure. Without this the renderer can't slow a flooding source,
// so a busy stream grows the write queue and xterm's buffer unbounded. The
// controller tracks bytes received-but-not-yet-rendered and asks the main
// process to pause/resume the session's source stream at these watermarks.
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
const getFlowController = (
ctx: TerminalSessionStartersContext,
term: XTerm,
): OutputFlowController => {
let controller = terminalFlowControllers.get(term);
if (!controller) {
controller = createOutputFlowController({
highWaterMark: FLOW_HIGH_WATER_MARK,
lowWaterMark: FLOW_LOW_WATER_MARK,
onPause: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
},
onResume: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
},
});
terminalFlowControllers.set(term, controller);
}
return controller;
};
const writeTerminalLine = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -268,9 +303,11 @@ const writeSessionData = (
term: XTerm,
data: string,
) => {
const flow = getFlowController(ctx, term);
flow.received(data.length);
enqueueTerminalWrite(term, (done) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const forcePromptNewLine = settings?.forcePromptNewLine ?? true;
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) {
ctx.promptLineBreakStateRef.current.pendingCommand = false;
ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false;
@@ -301,6 +338,8 @@ const writeSessionData = (
handleTerminalOutputAutoScroll(ctx, term);
}
done();
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
flow.written(data.length);
};
term.write(displayData, afterWrite);
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
},
) => {
ctx.sessionRef.current = id;
// Clear any stale back-pressure accounting from a prior session on this term.
getFlowController(ctx, term).reset();
ctx.onSessionAttached?.(id);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
@@ -375,8 +416,32 @@ const attachSessionToTerminal = (
});
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
/**
* Split a (possibly multi-line) startup command into non-empty lines, dropping
* blank/whitespace-only lines but preserving each line's content verbatim — so
* a single-line command stays byte-identical to what the user typed (e.g. a
* leading space for `HISTCONTROL=ignorespace` is kept). Trailing `\r` from
* CRLF input is normalized away.
*/
export function splitStartupCommandLines(commandText: string): string[] {
return String(commandText || "")
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.trim().length > 0);
}
/** Clamp a configured startup-command delay; fall back to the default when unset/invalid. */
export function normalizeStartupCommandDelay(raw: number | undefined): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : STARTUP_COMMAND_DEFAULT_DELAY_MS;
return Math.max(0, Math.min(STARTUP_COMMAND_MAX_DELAY_MS, value));
}
const scheduleStartupCommand = (
ctx: TerminalSessionStartersContext,
term: XTerm,
id: string,
onSettled?: () => void,
): (() => void) | undefined => {
@@ -385,24 +450,65 @@ const scheduleStartupCommand = (
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
const timeoutId = setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const delayMs = normalizeStartupCommandDelay(settings?.startupCommandDelayMs);
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const sessionIsCurrent = () =>
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
// Enter and no line-splitting — unchanged behavior.
if (ctx.noAutoRun) {
timeoutId = setTimeout(() => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
onSettled?.();
}, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
// Auto-run: send each non-empty line in sequence, waiting delayMs before the
// first and between each, so a line runs inside any sub-shell opened by a
// previous line (e.g. `sudo -i` then `alias ...`).
const lines = splitStartupCommandLines(commandToRun);
if (lines.length === 0) {
onSettled?.();
return undefined;
}
let index = 0;
const runNext = () => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
const suffix = ctx.noAutoRun ? "" : "\r";
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
automated: true,
});
if (!ctx.noAutoRun) {
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
const line = lines[index];
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
index += 1;
if (index < lines.length) {
timeoutId = setTimeout(runNext, delayMs);
} else {
onSettled?.();
}
onSettled?.();
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
return () => clearTimeout(timeoutId);
};
timeoutId = setTimeout(runNext, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
};
const runDistroDetection = async (
@@ -877,7 +983,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
scheduleStartupCommand(ctx, id);
scheduleStartupCommand(ctx, term, id);
// Run OS detection only after successful connection. Mint a fresh
// token for this specific connection attempt and register it as
@@ -991,7 +1097,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionId,
() => {
disposeAutoLoginListener();
cancelPendingStartupCommand = scheduleStartupCommand(ctx, telnetSessionId, () => {
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
cancelPendingStartupCommand = undefined;
disposeAutoLoginCancelListener();
});
@@ -1158,7 +1264,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
scheduleStartupCommand(ctx, id);
scheduleStartupCommand(ctx, term, id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
@@ -1203,6 +1309,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
ctx.sessionRef.current = id;
getFlowController(ctx, term).reset();
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {

View File

@@ -4,6 +4,50 @@ import assert from "node:assert/strict";
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import { createPromptLineBreakState } from "./promptLineBreak";
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
return {
buffer: {
active: {
cursorX,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
},
};
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
buffer: {
active: {
cursorX,
cursorY,
baseY: 0,
getLine(line: number) {
const lineText = rows[line];
if (lineText === undefined) return undefined;
return {
isWrapped: line > 0,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("command execution arms prompt line break even without command history callback", () => {
const promptState = createPromptLineBreakState();
const commandBufferRef = { current: "echo ok" };
@@ -12,8 +56,6 @@ test("command execution arms prompt line break even without command history call
host: {
id: "host-1",
label: "Host",
hostname: "example.test",
username: "alice",
},
sessionId: "session-1",
commandBufferRef,
@@ -23,3 +65,332 @@ test("command execution arms prompt line break even without command history call
assert.equal(commandBufferRef.current, "");
assert.equal(promptState.pendingCommand, true);
});
test("command execution caches the current prompt instead of prompt-like command text", () => {
const promptState = createPromptLineBreakState();
const commandBufferRef = { current: "echo > out" };
recordTerminalCommandExecution("echo > out", {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
promptLineBreakStateRef: { current: promptState },
}, createFakeTerm("$ echo > out") as never);
assert.equal(promptState.lastPromptText, "$ ");
assert.equal(promptState.pendingCommand, true);
});
test("command execution does not write interactive program input to shell history", () => {
const cases = [
{ lineText: "sftp> get file", command: "get file" },
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
{ lineText: "hive (default)> select 1", command: "select 1" },
{ lineText: "trino:tpch> select 1", command: "select 1" },
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
{ lineText: "pry(main)> whereami", command: "whereami" },
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
{ lineText: "SQL> select 1", command: "select 1" },
{ lineText: "test> db.stats()", command: "db.stats()" },
{ lineText: "test> db", command: "db" },
{ lineText: "test> const x = 1", command: "const x = 1" },
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "test> print(1)", command: "print(1)" },
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
{ lineText: "test> rs.status()", command: "rs.status()" },
{ lineText: "rs0 primary reporting> exit", command: "exit" },
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
];
for (const { lineText, command } of cases) {
const commandBufferRef = { current: command };
const promptState = createPromptLineBreakState();
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
promptLineBreakStateRef: { current: promptState },
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(lineText) as never);
assert.deepEqual(recorded, [], lineText);
assert.equal(commandBufferRef.current, "", lineText);
assert.equal(promptState.lastPromptText, "", lineText);
assert.equal(promptState.pendingCommand, true, lineText);
}
});
test("command execution does not record interactive input before echo appears", () => {
const cases = [
{ lineText: "test> ", command: "rs.status()" },
{ lineText: "test> ", command: "db" },
{ lineText: "test> ", command: "const x = 1" },
{ lineText: "test> ", command: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
];
for (const { lineText, command } of cases) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(lineText) as never);
assert.deepEqual(recorded, [], lineText);
assert.equal(commandBufferRef.current, "", lineText);
}
});
test("command execution does not record wrapped interactive program input", () => {
const cases = [
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
{ rows: ["test> d", "b"], command: "db" },
];
for (const { rows, command } of cases) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
assert.deepEqual(recorded, [], rows[0]);
assert.equal(commandBufferRef.current, "", rows[0]);
}
});
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
const prompts = ["test> ", "admin> ", "local> ", "config> "];
const commands = ["deploy", "exit", "help", "show dbs"];
for (const prompt of prompts) {
for (const command of commands) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(`${prompt}${command}`) as never);
assert.deepEqual(recorded, [command], `${prompt}${command}`);
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
}
}
});
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
const cases = [
{ rows: ["test> hel", "p"], command: "help" },
{ rows: ["test> show ", "dbs"], command: "show dbs" },
{ rows: ["admin> ex", "it"], command: "exit" },
{ rows: ["local> dep", "loy"], command: "deploy" },
];
for (const { rows, command } of cases) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
assert.deepEqual(recorded, [command], rows[0]);
assert.equal(commandBufferRef.current, "", rows[0]);
}
});
test("command execution records short commands when standard prompt echo lags by one character", () => {
const cases = [
{ lineText: "$ l", command: "ls" },
{ lineText: "$ c", command: "cd" },
{ lineText: "prod-web> l", command: "ls" },
{ lineText: "prod> l", command: "ls" },
{ lineText: "prod.web> l", command: "ls" },
{ lineText: "user@host:~$ l", command: "ls" },
{ lineText: "[user@host ~]$ l", command: "ls" },
{ lineText: "➜ netcatty $ l", command: "ls" },
{ lineText: "➜ git l", command: "ls" },
{ lineText: "➜ git np", command: "npm" },
];
for (const { lineText, command } of cases) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(lineText) as never);
assert.deepEqual(recorded, [command], lineText);
assert.equal(commandBufferRef.current, "", lineText);
}
});
test("command execution records direct sends from themed bare directory prompts", () => {
const cases = [
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
];
for (const { lineText, command, promptText } of cases) {
const commandBufferRef = { current: command };
const promptState = createPromptLineBreakState();
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
promptLineBreakStateRef: { current: promptState },
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(lineText) as never);
assert.deepEqual(recorded, [command], lineText);
assert.equal(commandBufferRef.current, "", lineText);
assert.equal(promptState.lastPromptText, promptText, lineText);
assert.equal(promptState.pendingCommand, true, lineText);
}
});
test("command execution still records host-style greater-than prompts", () => {
const prompts = [
"prod-web> ",
"prod> ",
"prod.web> ",
"server> ",
"staging> ",
"webdb> ",
"prod.db> ",
];
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
for (const prompt of prompts) {
for (const command of commands) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(`${prompt}${command}`) as never);
assert.deepEqual(recorded, [command], `${prompt}${command}`);
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
}
}
});
test("command execution records direct sends from host-style greater-than prompts", () => {
const cases = [
{ lineText: "server> ", command: "exit" },
{ lineText: "staging> ", command: "show dbs" },
{ lineText: "server> ", command: "db.stats()" },
{ lineText: "webdb> ", command: "deploy" },
{ lineText: "prod.db> ", command: "deploy" },
{ lineText: "test> ", command: "deploy" },
{ lineText: "test> ", command: "exit" },
{ lineText: "test> ", command: "help" },
{ lineText: "test> ", command: "show dbs" },
{ lineText: "admin> ", command: "deploy" },
];
for (const { lineText, command } of cases) {
const commandBufferRef = { current: command };
const recorded: string[] = [];
recordTerminalCommandExecution(command, {
host: {
id: "host-1",
label: "Host",
},
sessionId: "session-1",
commandBufferRef,
onCommandExecuted(nextCommand) {
recorded.push(nextCommand);
},
}, createFakeTerm(lineText) as never);
assert.deepEqual(recorded, [command], lineText);
assert.equal(commandBufferRef.current, "", lineText);
}
});

View File

@@ -43,8 +43,12 @@ import {
} from "./kittyKeyboardProtocol";
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
shouldBroadcastTerminalUserInput,
shouldSuppressTerminalInputScrollForUserPaste,
@@ -78,6 +82,13 @@ export type XTermRuntime = {
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
keywordHighlighter: KeywordHighlighter;
/**
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
* next frame. No-op when the DOM renderer is active. Used to recover from the
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
* fall into after font changes or device pixel ratio changes.
*/
clearTextureAtlas: () => void;
};
export type CreateXTermRuntimeContext = {
@@ -157,6 +168,15 @@ const detectPlatform = (): XTermPlatform => {
return "darwin";
};
const csiParamsInclude = (
params: readonly (number | number[])[],
target: number,
): boolean => params.some((param) => (
Array.isArray(param)
? param.includes(target)
: param === target
));
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
@@ -276,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
...terminalAltKeyOptions(altIsMeta),
wordSeparator,
theme: {
...ctx.terminalTheme.colors,
@@ -376,6 +396,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? "dom"
: "webgl";
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
// (claude code / gemini cli / opencode and other full-screen agents), font
// changes, and device pixel ratio changes can leave that atlas in a corrupted
// state that persists for the life of the terminal — the "garbled / 花屏"
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
// the atlas forces glyphs to re-rasterize at the correct scale on the next
// frame. No-op for the DOM renderer.
const clearWebglTextureAtlas = () => {
if (!webglAddon) return;
try {
webglAddon.clearTextureAtlas();
} catch (err) {
logger.warn("[XTerm] clearTextureAtlas failed", err);
}
};
// Recover the renderer when the device pixel ratio changes (moving the window
// between monitors with different DPI, or changing OS display scaling — a
// common Windows trigger). matchMedia change does not fire a normal resize, so
// this is needed in addition to the resize handling below.
let stopDprWatch: () => void = () => {};
if (
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
) {
stopDprWatch = watchDevicePixelRatio({
getDevicePixelRatio: () => window.devicePixelRatio || 1,
matchMedia: (query) => window.matchMedia(query),
onChange: () => {
clearWebglTextureAtlas();
try {
fitAddon.fit();
} catch (err) {
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
}
},
});
}
const webLinksAddon = new WebLinksAddon((event, uri) => {
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
let shouldOpen = false;
@@ -514,7 +573,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.onAutocompleteInput?.(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun) {
recordTerminalCommandExecution(snippet.command, ctx);
recordTerminalCommandExecution(snippet.command, ctx, term);
}
return false;
}
@@ -599,6 +658,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
// #826). After kitty mode so apps using the kitty protocol keep their own
// arrow encoding; read live so the toggle applies without reconnecting.
const wordJumpSequence = optionArrowWordJumpSequence(
e,
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
isMacPlatform(),
);
if (wordJumpSequence) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
ctx.onAutocompleteInput?.(wordJumpSequence);
ctx.terminalBackend.writeToSession(id, wordJumpSequence);
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
}
scrollToBottomAfterInput(wordJumpSequence);
return false;
}
}
return true;
});
@@ -687,7 +769,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (ctx.statusRef.current === "connected") {
if (data === "\r" || data === "\n") {
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx);
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
} else if (data === "\x7f" || data === "\b") {
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
} else if (data === "\x03") {
@@ -721,6 +803,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return !wipeAllowed;
});
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
if (csiParamsInclude(params, 6)) {
markExpectedTerminalCursorPositionReport(term);
}
return false;
};
const cursorPositionReportRequestDisposables = [
term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
];
const writeKittyKeyboardReply = (payload: string) => {
const id = ctx.sessionRef.current;
if (!id) return;
@@ -836,6 +930,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
// dimensions re-rasterize cleanly (issue #1049).
clearWebglTextureAtlas();
const id = ctx.sessionRef.current;
if (!id) return;
if (resizeTimeout) clearTimeout(resizeTimeout);
@@ -854,10 +951,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
serializeAddon,
searchAddon,
keywordHighlighter,
clearTextureAtlas: clearWebglTextureAtlas,
dispose: () => {
cleanupMiddleClick?.();
stopDprWatch();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
disposable.dispose();
}
kittyKeyboardDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
// readline/zle does backward-word / forward-word out of the box (Termius-style).
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
key: "ArrowLeft",
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...over,
});
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
});
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
});
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
});
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
});
test("no Option held → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
});
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
});
test("non-arrow keys → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
});

View File

@@ -0,0 +1,33 @@
export interface OptionArrowKeyEvent {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
/**
* macOS Option+←/→ word-jump (discussion #826).
*
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
* readline/zle does backward-word / forward-word without per-host bindkey setup.
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
* default ^[[1;3D / ^[[1;3C is left untouched.
*
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
* devices (discussion #826 review).
*/
export function optionArrowWordJumpSequence(
e: OptionArrowKeyEvent,
enabled: boolean,
isMac: boolean,
): string | null {
if (!enabled || !isMac) return null;
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
return null;
}

View File

@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createOutputFlowController } from "./outputFlowController.ts";
function make(high = 100, low = 30) {
const events: string[] = [];
const controller = createOutputFlowController({
highWaterMark: high,
lowWaterMark: low,
onPause: () => events.push("pause"),
onResume: () => events.push("resume"),
});
return { controller, events };
}
test("does not pause while below the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(50);
controller.received(49); // 99 < 100
assert.deepEqual(events, []);
assert.equal(controller.isPaused(), false);
});
test("pauses once when crossing the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(60);
controller.received(60); // 120 >= 100 -> pause
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
// Further received while already paused must not re-fire pause.
controller.received(100);
assert.deepEqual(events, ["pause"]);
});
test("resumes once when draining to at/below the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(50); // 70 still > 30, no resume
assert.deepEqual(events, ["pause"]);
controller.written(50); // 20 <= 30 -> resume
assert.deepEqual(events, ["pause", "resume"]);
assert.equal(controller.isPaused(), false);
});
test("does not resume when still above the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(80); // 40 > 30
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
});
test("never lets pending go negative", () => {
const { controller } = make(100, 30);
controller.received(10);
controller.written(50); // over-written
assert.equal(controller.pendingBytes(), 0);
});
test("supports repeated pause/resume cycles", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(120); // resume (0 <= 30)
controller.received(120); // pause again
controller.written(120); // resume again
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
});
test("reset clears state without firing callbacks", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.reset();
assert.equal(controller.isPaused(), false);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, ["pause"]); // reset itself is silent
// A fresh cycle works after reset.
controller.received(120);
assert.deepEqual(events, ["pause", "pause"]);
});
test("ignores non-positive amounts", () => {
const { controller, events } = make(100, 30);
controller.received(0);
controller.written(0);
controller.received(-5);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,73 @@
/**
* Watermark-based flow control for terminal output.
*
* SSH/PTY output has no back-pressure by default: the source streams as fast as
* it can, the main process forwards it over IPC, and the renderer queues every
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
* buffer grow without bound — memory climbs and the whole UI, typing included,
* janks.
*
* This tracks bytes that have been received but not yet acknowledged by xterm's
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
* backlog hovers near zero, so this never engages.
*/
export interface OutputFlowController {
/** Account bytes handed to xterm (call when a chunk is received). */
received(bytes: number): void;
/** Account bytes whose xterm write callback has fired. */
written(bytes: number): void;
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
reset(): void;
pendingBytes(): number;
isPaused(): boolean;
}
export interface OutputFlowControllerOptions {
highWaterMark: number;
lowWaterMark: number;
/** Asked to pause the source when the backlog crosses the high watermark. */
onPause: () => void;
/** Asked to resume the source when the backlog drains to the low watermark. */
onResume: () => void;
}
export function createOutputFlowController(
options: OutputFlowControllerOptions,
): OutputFlowController {
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
let pending = 0;
let paused = false;
return {
received(bytes: number): void {
if (bytes <= 0) return;
pending += bytes;
if (!paused && pending >= highWaterMark) {
paused = true;
onPause();
}
},
written(bytes: number): void {
if (bytes <= 0) return;
pending -= bytes;
if (pending < 0) pending = 0;
if (paused && pending <= lowWaterMark) {
paused = false;
onResume();
}
},
reset(): void {
pending = 0;
paused = false;
},
pendingBytes(): number {
return pending;
},
isPaused(): boolean {
return paused;
},
};
}

View File

@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
createPromptLineBreakState,
insertPromptLineBreakBeforePrompt,
markPromptLineBreakCommandPending,
prepareTerminalDataForPromptLineBreak,
syncPromptLineBreakState,
} from "./promptLineBreak";
@@ -29,6 +30,29 @@ function createFakeTerm(lineText = "", cursorX = lineText.length) {
};
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
buffer: {
active: {
cursorX,
cursorY,
baseY: 0,
getLine(line: number) {
const lineText = rows[line];
if (lineText === undefined) return undefined;
return {
isWrapped: line > 0,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("does not insert before prompt-like suffixes in a larger output chunk", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0),
@@ -71,6 +95,56 @@ test("does not insert for output chunks that only end with the cached prompt tex
);
});
test("does not insert before an ambiguous prompt suffix inside output", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("world$ ", "$ ", 5),
"world$ ",
);
});
test("does not insert before prompt-like output after a line break", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("\r\nhello$ ", "$ ", 0),
"\r\nhello$ ",
);
});
test("inserts before a distinct root prompt in the same output chunk", () => {
const prompt = "[root@iZwz9ftrhzy4b3hduolf6yZ ~]# ";
assert.equal(
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
`file tail\r\n${prompt}`,
);
});
test("inserts before a distinct conda prompt in the same output chunk", () => {
const prompt = "(base) rynn@aiserver:~$ ";
assert.equal(
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
`file tail\r\n${prompt}`,
);
});
test("inserts before a distinct no-space root prompt in the same output chunk", () => {
const prompt = " root@stwo:~#";
assert.equal(
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
`file tail\r\n${prompt}`,
);
});
test("does not insert before an already separated distinct prompt", () => {
const prompt = "(base) rynn@aiserver:~$ ";
assert.equal(
insertPromptLineBreakBeforePrompt(`file tail\r\n${prompt}`, prompt, 0),
`file tail\r\n${prompt}`,
);
});
test("does not refresh cached prompt from output that only ends with the prompt text", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "$ ";
@@ -90,10 +164,724 @@ test("does not refresh cached prompt from output that only ends with the prompt
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
assert.equal(state.lastPromptText, "$ ");
assert.equal(state.pendingCommand, false);
assert.equal(state.pendingCommand, true);
assert.equal(state.suppressNextPromptCache, false);
});
test("keeps waiting for the real prompt after an output suffix matches the prompt text", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "$ ";
state.pendingCommand = true;
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("", 0) as never,
"total $ ",
state,
true,
),
"total $ ",
);
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("total $ ", 8) as never,
"$ ",
state,
true,
),
"\r\n$ ",
);
});
test("keeps waiting after prompt-like output on a fresh line", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "$ ";
state.pendingCommand = true;
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("", 0) as never,
"\r\nhello$ ",
state,
true,
),
"\r\nhello$ ",
);
syncPromptLineBreakState(createFakeTerm("hello$ ") as never, state);
assert.equal(state.lastPromptText, "$ ");
assert.equal(state.pendingCommand, true);
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("hello$ ", 7) as never,
"$ ",
state,
true,
),
"\r\n$ ",
);
});
test("prepares a same-chunk cat output break for a distinct prompt", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "(base) rynn@aiserver:~$ ";
state.pendingCommand = true;
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("", 0) as never,
"without trailing newline(base) rynn@aiserver:~$ ",
state,
true,
),
"without trailing newline\r\n(base) rynn@aiserver:~$ ",
);
assert.equal(state.suppressNextPromptCache, false);
});
test("caches a no-space root prompt from typed command alignment", () => {
const prompt = " root@stwo:~#";
const command = "printf ok";
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}${command}`) as never,
command,
);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, true);
});
test("caches a no-space root prompt when command echo lags", () => {
const prompt = " root@stwo:~#";
const command = "printf ok";
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}${command.slice(0, -1)}`) as never,
command,
);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, true);
});
test("caches a no-space root prompt when command echo lags by a word", () => {
const prompt = " root@stwo:~#";
const command = "printf ok";
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}printf `) as never,
command,
);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, true);
});
test("caches a no-space root prompt when a longer command echo lags by a word", () => {
const prompt = "root@host:~#";
const command = "git status";
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}git `) as never,
command,
);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, true);
});
test("caches a no-space root prompt when command echo lags mid-word", () => {
const prompt = "root@host:~#";
const command = "git status";
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}git st`) as never,
command,
);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, true);
});
test("caches a standard prompt when command echo lags near completion", () => {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm("$ git statu") as never,
"git status",
);
assert.equal(state.lastPromptText, "$ ");
assert.equal(state.pendingCommand, true);
});
test("caches a standard prompt when command echo lags after a word boundary", () => {
const cases = ["$ git ", "$ git st"];
for (const lineText of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"git status",
);
assert.equal(state.lastPromptText, "$ ", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("caches a standard prompt when short command echo lags by one character", () => {
const cases = [
{ lineText: "$ l", command: "ls" },
{ lineText: "$ c", command: "cd" },
{ lineText: "prod-web> l", command: "ls", promptText: "prod-web> " },
{ lineText: "prod> l", command: "ls", promptText: "prod> " },
{ lineText: "prod.web> l", command: "ls", promptText: "prod.web> " },
{ lineText: "user@host:~$ l", command: "ls", promptText: "user@host:~$ " },
{ lineText: "[user@host ~]$ l", command: "ls", promptText: "[user@host ~]$ " },
{ lineText: "➜ netcatty $ l", command: "ls", promptText: "➜ netcatty $ " },
{ lineText: "➜ git l", command: "ls", promptText: "➜ git " },
{ lineText: "➜ git np", command: "npm", promptText: "➜ git " },
];
for (const { lineText, command, promptText = "$ " } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, promptText, lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("caches a no-space root prompt when a short command echo lags by a word", () => {
const prompt = "root@host:~#";
const cases = [
{ echoedInput: "ls ", command: "ls -la" },
{ echoedInput: "cd ", command: "cd /tmp" },
];
for (const { echoedInput, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}${echoedInput}`) as never,
command,
);
assert.equal(state.lastPromptText, prompt, command);
assert.equal(state.pendingCommand, true, command);
}
});
test("caches a no-space root prompt when a short command echo lags by one character", () => {
const prompt = " root@stwo:~#";
const cases = [
{ echoedInput: "l", command: "ls" },
{ echoedInput: "c", command: "cd" },
];
for (const { echoedInput, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(`${prompt}${echoedInput}`) as never,
command,
);
assert.equal(state.lastPromptText, prompt, command);
assert.equal(state.pendingCommand, true, command);
}
});
test("does not cache a stale command as prompt text", () => {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm("$ ls") as never,
"sudo",
);
assert.equal(state.lastPromptText, "");
assert.equal(state.pendingCommand, true);
});
test("does not cache common interactive program prompts", () => {
const cases = [
{ lineText: "sftp> get file", command: "get file" },
{ lineText: "ftp> ls", command: "ls" },
{ lineText: "ghci> :t map", command: ":t map" },
{ lineText: "node> .help", command: ".help" },
{ lineText: "mongo> db.stats()", command: "db.stats()" },
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
{ lineText: "test> const x = 1", command: "const x = 1" },
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
{ lineText: "rs0 primary reporting> exit", command: "exit" },
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
{ lineText: "pry(main)> whereami", command: "whereami" },
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
{ lineText: "SQL> select 1", command: "select 1" },
{ lineText: "cqlsh> select * from users", command: "select * from users" },
{ lineText: "hive> select 1", command: "select 1" },
{ lineText: "spark-sql> select 1", command: "select 1" },
{ lineText: "jshell> /help", command: "/help" },
{ lineText: " ...> System.out.println(1)", command: "System.out.println(1)" },
{ lineText: "ksql> select 1", command: "select 1" },
{ lineText: "trino> select 1", command: "select 1" },
{ lineText: "trino:tpch> select 1", command: "select 1" },
{ lineText: "presto> show catalogs", command: "show catalogs" },
{ lineText: "presto:default> show tables", command: "show tables" },
{ lineText: "duckdb> select 1", command: "select 1" },
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
{ lineText: "hive (default)> select 1", command: "select 1" },
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", command: "select 1" },
{ lineText: "spark-sql (default)> select 1", command: "select 1" },
{ lineText: "test> db.stats()", command: "db.stats()" },
{ lineText: "test> db", command: "db" },
{ lineText: "test> const x = 1", command: "const x = 1" },
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
{ lineText: "test> rs.status()", command: "rs.status()" },
{ lineText: "test> print(1)", command: "print(1)" },
{ lineText: "test> 1 + 1", command: "1 + 1" },
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
];
for (const { lineText, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache wrapped common interactive program prompts", () => {
const cases = [
{ rows: ["sftp> get very-long-", "remote-file"], command: "get very-long-remote-file" },
{ rows: ["node> console.", "log('ok')"], command: "console.log('ok')" },
{ rows: ["mongo> db.", "stats()"], command: "db.stats()" },
{ rows: ["cqlsh> select *", " from users"], command: "select * from users" },
{ rows: ["jshell> System.out.", "println(1)"], command: "System.out.println(1)" },
{ rows: [" ...> System.out.", "println(1)"], command: "System.out.println(1)" },
{ rows: ["trino> select", " 1"], command: "select 1" },
{ rows: ["trino:tpch> select", " 1"], command: "select 1" },
{ rows: ["duckdb> select", " 1"], command: "select 1" },
{ rows: ["cqlsh:cycling> select *", " from cyclist"], command: "select * from cyclist" },
{ rows: ["hive (default)> select", " 1"], command: "select 1" },
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], command: "select 1" },
{ rows: ["test> db.", "stats()"], command: "db.stats()" },
{ rows: ["test> d", "b"], command: "db" },
{ rows: ["rs0:PRIMARY> db.", "stats()"], command: "db.stats()" },
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], command: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], command: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], command: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], command: "const x = 1" },
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
{ rows: ["rs0 primary test> db.", "stats()"], command: "db.stats()" },
{ rows: ["test> print", "(1)"], command: "print(1)" },
{ rows: ["test> 1 ", "+ 1"], command: "1 + 1" },
{ rows: ["admin@localhost:27017> db.", "stats()"], command: "db.stats()" },
];
for (const { rows, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
command,
);
assert.equal(state.lastPromptText, "", rows[0]);
assert.equal(state.pendingCommand, true, rows[0]);
}
});
test("caches wrapped non-Mongo-looking default-name greater-than prompts", () => {
const cases = [
{ rows: ["test> hel", "p"], command: "help", promptText: "test> " },
{ rows: ["test> show ", "dbs"], command: "show dbs", promptText: "test> " },
{ rows: ["admin> ex", "it"], command: "exit", promptText: "admin> " },
{ rows: ["local> dep", "loy"], command: "deploy", promptText: "local> " },
];
for (const { rows, command, promptText } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
command,
);
assert.equal(state.lastPromptText, promptText, rows[0]);
assert.equal(state.pendingCommand, true, rows[0]);
}
});
test("does not cache a live command suffix as prompt text", () => {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm("$ echo sudo") as never,
"sudo",
);
assert.equal(state.lastPromptText, "");
assert.equal(state.pendingCommand, true);
});
test("does not cache host prompt command symbols as prompt text", () => {
const prompt = "user@host:~$ ";
const cases = [
`${prompt}echo # sudo`,
`${prompt}printf % sudo`,
`${prompt}echo $ sudo`,
];
for (const lineText of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache a themed prompt live command suffix as prompt text", () => {
for (const lineText of [
"➜ ~ echo sudo",
"➜ echo sudo",
"➜ make sudo",
"➜ docker sudo",
"➜ ./script sudo",
"➜ ./script sudo",
"➜ ~ echo # sudo",
]) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("caches themed prompt decorations from typed command alignment", () => {
const cases = [
{ lineText: "➜ ~/repo do", command: "do", promptText: "➜ ~/repo " },
{
lineText: "➜ netcatty git:(main) ✗ ls",
command: "ls",
promptText: "➜ netcatty git:(main) ✗ ",
},
{
lineText: "➜ netcatty git:(main) ✗ + ls",
command: "ls",
promptText: "➜ netcatty git:(main) ✗ + ",
},
{ lineText: "➜ netcatty ✗ $ ls", command: "ls", promptText: "➜ netcatty ✗ $ " },
{ lineText: "➜ netcatty $ ls", command: "ls", promptText: "➜ netcatty $ " },
];
for (const { lineText, command, promptText } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, promptText, lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("caches themed prompt decorations when command echo lags", () => {
const cases = [
{ lineText: "➜ ~ git ", command: "git status", promptText: "➜ ~ " },
{ lineText: "➜ ~ git st", command: "git status", promptText: "➜ ~ " },
{
lineText: "➜ netcatty git:(main) ✗ git ",
command: "git status",
promptText: "➜ netcatty git:(main) ✗ ",
},
{
lineText: "➜ netcatty git:(main) ✗ git st",
command: "git status",
promptText: "➜ netcatty git:(main) ✗ ",
},
];
for (const { lineText, command, promptText } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, promptText, lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("caches themed bare directory prompts for direct sends before command echo", () => {
const cases = [
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
];
for (const { lineText, command, promptText } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, promptText, lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache interactive prompts for direct sends before command echo", () => {
const cases = [
{ lineText: "test> ", command: "const x = 1" },
{ lineText: "test> ", command: "await db.users.findOne()" },
{ lineText: "test> ", command: "db" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
];
for (const { lineText, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("clears an old cached prompt when a direct send is interactive", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "rs0 [direct: primary] reporting> ";
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm("rs0 [direct: primary] reporting> ") as never,
"db.stats()",
);
assert.equal(state.lastPromptText, "");
assert.equal(state.pendingCommand, true);
});
test("caches host-style greater-than prompts for direct sends before command echo", () => {
const cases = [
{ lineText: "server> ", command: "exit" },
{ lineText: "staging> ", command: "show dbs" },
{ lineText: "server> ", command: "db.stats()" },
{ lineText: "webdb> ", command: "deploy" },
{ lineText: "prod.db> ", command: "deploy" },
{ lineText: "test> ", command: "deploy" },
{ lineText: "test> ", command: "exit" },
{ lineText: "test> ", command: "help" },
{ lineText: "test> ", command: "show dbs" },
{ lineText: "admin> ", command: "deploy" },
];
for (const { lineText, command } of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
command,
);
assert.equal(state.lastPromptText, lineText, lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache a live path suffix as prompt text", () => {
for (const lineText of ["$ cd ~/sudo", "$ cat > sudo", "$ echo path#sudo"]) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache a stale command from a standard prompt echo prefix", () => {
for (const lineText of ["$ s", "$ su", "$ sud"]) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache partial stale commands after a no-space prompt", () => {
const prompt = " root@stwo:~#";
for (const lineText of [`${prompt}s`, `${prompt}sud`]) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("does not cache stale command suffixes after a no-space prompt", () => {
const prompt = " root@stwo:~#";
const cases = [
`${prompt}cat > sudo`,
`${prompt}echo # sudo`,
`${prompt}echo $ sudo`,
`${prompt}printf % sudo`,
`${prompt}echo path#sudo`,
`${prompt}> sudo`,
`${prompt}# sudo`,
`${prompt}% sudo`,
`${prompt}$ sudo`,
];
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
for (const lineText of cases) {
const state = createPromptLineBreakState();
markPromptLineBreakCommandPending(
{ current: state },
createFakeTerm(lineText) as never,
"sudo",
);
assert.equal(state.lastPromptText, "", lineText);
assert.equal(state.pendingCommand, true, lineText);
}
});
test("syncs prompts that contain prompt-like symbols", () => {
const prompts = [
"user@host ~/foo# bar $ ",
"user@host ~/foo# git $ ",
"user@host ~/foo#git $ ",
"root@host ~/foo# bar # ",
"root@host ~/foo#bar # ",
"fish@host ~/foo# bar % ",
"fish@host ~/foo%bar % ",
"user@host:~/foo# bar $ ",
"user@host ~/repo # $ ",
"➜ ~ $ ",
"user@host ~/foo% bar $ ",
"user@host ~/foo> bar $ ",
"user@host ~/foo# bar> ",
"user@host ~/foo# bar ",
"user@host ~/foo#bar> ",
];
for (const prompt of prompts) {
const state = createPromptLineBreakState();
syncPromptLineBreakState(createFakeTerm(prompt) as never, state);
assert.equal(state.lastPromptText, prompt, prompt);
assert.equal(state.pendingCommand, false, prompt);
}
});
test("syncs a no-space root prompt without xterm row padding", () => {
const prompt = " root@stwo:~#";
const state = createPromptLineBreakState();
syncPromptLineBreakState(createFakeTerm(`${prompt} `, prompt.length) as never, state);
assert.equal(state.lastPromptText, prompt);
assert.equal(state.pendingCommand, false);
});
test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "old$ ";
@@ -148,6 +936,6 @@ test("does not refresh cached prompt from an unchanged mid-line write without a
syncPromptLineBreakState(createFakeTerm("outputnew$ ") as never, state);
assert.equal(state.lastPromptText, "old$ ");
assert.equal(state.pendingCommand, false);
assert.equal(state.pendingCommand, true);
assert.equal(state.suppressNextPromptCache, false);
});

View File

@@ -1,6 +1,11 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import { detectPrompt } from "../autocomplete/promptDetector";
import {
detectPrompt,
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
} from "../autocomplete/promptDetector";
export type PromptLineBreakState = {
lastPromptText: string;
@@ -86,6 +91,12 @@ const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean =>
return prefixText.length > 0 && !endsWithLineBreak(prefixText);
};
const isDistinctPromptText = (promptText: string): boolean => {
const trimmed = promptText.trim();
if (trimmed.length >= 8) return true;
return trimmed.length >= 6 && /[@:\\/]/.test(trimmed);
};
const getCursorX = (term: XTerm): number => {
try {
return term.buffer.active.cursorX;
@@ -104,12 +115,71 @@ export function createPromptLineBreakState(): PromptLineBreakState {
export function markPromptLineBreakCommandPending(
stateRef?: RefObject<PromptLineBreakState>,
term?: XTerm | null,
command?: string,
): void {
if (!stateRef?.current) return;
if (term) {
const cachedFromCommand = command
? cachePromptLineBreakPromptFromCommand(term, stateRef.current, command)
: false;
if (!cachedFromCommand) {
cachePromptLineBreakPrompt(term, stateRef.current);
}
}
stateRef.current.pendingCommand = true;
stateRef.current.suppressNextPromptCache = false;
}
function cachePromptLineBreakPromptFromCommand(
term: XTerm,
state: PromptLineBreakState | undefined,
command: string,
): boolean {
const trimmedCommand = command.trim();
if (!state || trimmedCommand.length === 0) return false;
const aligned = getAlignedPrompt(term, trimmedCommand, true);
if (!aligned.prompt.isAtPrompt) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return false;
}
if (isNonPromptLine(`${aligned.prompt.promptText}${trimmedCommand}`)) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return true;
}
const prompt =
aligned.alignedTyped === trimmedCommand
? aligned.prompt
: reconcilePromptWithExternalCommand(aligned.prompt, trimmedCommand);
if (!prompt) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return false;
}
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
return true;
}
export function cachePromptLineBreakPrompt(
term: XTerm,
state: PromptLineBreakState | undefined,
): void {
if (!state) return;
const prompt = detectPrompt(term);
if (!prompt.isAtPrompt) return;
if (prompt.userInput.length > 0) return;
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
}
export function insertPromptLineBreakBeforePrompt(
data: string,
promptText: string,
@@ -123,7 +193,10 @@ export function insertPromptLineBreakBeforePrompt(
const promptTextStart = mapped.text.length - promptText.length;
const prefixText = mapped.text.slice(0, promptTextStart);
if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data;
if (prefixText.length > 0) return data;
if (prefixText.length > 0) {
if (endsWithLineBreak(prefixText)) return data;
if (!isDistinctPromptText(promptText)) return data;
}
const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0;
return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`;
@@ -144,11 +217,11 @@ export function prepareTerminalDataForPromptLineBreak(
cursorXBeforeWrite,
);
const visibleText = mapVisibleText(data).text;
const ambiguousPromptSuffix = hasAmbiguousPromptSuffix(data, state.lastPromptText);
state.suppressNextPromptCache =
nextData === data &&
(cursorXBeforeWrite > 0 ||
hasAmbiguousPromptSuffix(data, state.lastPromptText)) &&
!containsLineReset(visibleText);
(ambiguousPromptSuffix ||
(cursorXBeforeWrite > 0 && !containsLineReset(visibleText)));
return nextData;
}
@@ -160,7 +233,6 @@ export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakSta
if (state.pendingCommand && state.suppressNextPromptCache) {
state.suppressNextPromptCache = false;
state.pendingCommand = false;
return;
}

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
type MediaQueryListLike,
watchDevicePixelRatio,
} from "./rendererDprWatch";
class FakeMediaQueryList implements MediaQueryListLike {
readonly query: string;
modernListeners: Array<() => void> = [];
legacyListeners: Array<() => void> = [];
private readonly supportsModern: boolean;
constructor(query: string, supportsModern = true) {
this.query = query;
this.supportsModern = supportsModern;
if (!supportsModern) {
// Strip the modern API to emulate legacy environments.
this.addEventListener = undefined;
this.removeEventListener = undefined;
}
}
addEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners.push(listener);
};
removeEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
};
addListener = (listener: () => void) => {
this.legacyListeners.push(listener);
};
removeListener = (listener: () => void) => {
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
};
trigger() {
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
}
get listenerCount() {
return this.modernListeners.length + this.legacyListeners.length;
}
}
function makeEnv(initialDpr: number, supportsModern = true) {
let dpr = initialDpr;
const created: FakeMediaQueryList[] = [];
return {
created,
getDevicePixelRatio: () => dpr,
matchMedia: (query: string) => {
const mql = new FakeMediaQueryList(query, supportsModern);
created.push(mql);
return mql;
},
setDpr: (value: number) => {
dpr = value;
},
};
}
test("registers a change listener for the current devicePixelRatio", () => {
const env = makeEnv(1);
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {},
});
assert.equal(env.created.length, 1);
assert.equal(env.created[0].query, "(resolution: 1dppx)");
assert.equal(env.created[0].listenerCount, 1);
});
test("invokes onChange when the media query reports a change", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(calls, 1);
});
test("re-registers for the new ratio so subsequent changes still fire", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(env.created.length, 2);
assert.equal(env.created[1].query, "(resolution: 2dppx)");
// The stale listener must be detached so it cannot double-fire.
assert.equal(env.created[0].listenerCount, 0);
env.setDpr(3);
env.created[1].trigger();
assert.equal(calls, 2);
});
test("cleanup stops further onChange callbacks", () => {
const env = makeEnv(1);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
stop();
assert.equal(env.created[0].listenerCount, 0);
env.created[0].trigger();
assert.equal(calls, 0);
});
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
const env = makeEnv(1, /* supportsModern */ false);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
assert.equal(env.created[0].legacyListeners.length, 1);
env.created[0].trigger();
assert.equal(calls, 1);
stop();
// After cleanup the most recently registered query has no listeners.
const latest = env.created[env.created.length - 1];
assert.equal(latest.listenerCount, 0);
});

View File

@@ -0,0 +1,72 @@
/**
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
* with different DPI, or changing the OS display scaling on Windows) and invokes
* a callback so the renderer can be repaired.
*
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
* device pixel ratio at creation time. When the ratio changes the cached glyphs
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
* corruption reported in issue #1049 that only goes away when a brand-new
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
* change so glyphs re-rasterize at the new scale.
*
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
* change we must re-register the listener against the new ratio.
*/
export interface MediaQueryListLike {
addEventListener?: (type: "change", listener: () => void) => void;
removeEventListener?: (type: "change", listener: () => void) => void;
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
}
export interface WatchDevicePixelRatioOptions {
getDevicePixelRatio: () => number;
matchMedia: (query: string) => MediaQueryListLike;
onChange: () => void;
}
/**
* Start watching for devicePixelRatio changes. Returns a cleanup function that
* removes the active listener.
*/
export function watchDevicePixelRatio(
options: WatchDevicePixelRatioOptions,
): () => void {
const { getDevicePixelRatio, matchMedia, onChange } = options;
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
const detach = () => {
if (!current) return;
const { mql, listener } = current;
if (mql.removeEventListener) {
mql.removeEventListener("change", listener);
} else if (mql.removeListener) {
mql.removeListener(listener);
}
current = null;
};
const attach = () => {
const dpr = getDevicePixelRatio();
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
const listener = () => {
// A media query only matches the ratio it was created with, so detach the
// stale listener and re-register against the new ratio before notifying.
detach();
attach();
onChange();
};
if (mql.addEventListener) {
mql.addEventListener("change", listener);
} else if (mql.addListener) {
mql.addListener(listener);
}
current = { mql, listener };
};
attach();
return detach;
}

View File

@@ -1,9 +1,15 @@
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Host } from "../../../types";
import {
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./promptLineBreak";
import {
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
} from "../autocomplete/promptDetector";
type TerminalCommandExecutionContext = {
host: Pick<Host, "id" | "label">;
@@ -18,14 +24,34 @@ type TerminalCommandExecutionContext = {
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
};
const shouldRecordShellHistory = (
command: string,
term?: XTerm | null,
): boolean => {
if (!term) return true;
const { prompt, alignedTyped } = getAlignedPrompt(term, command, true);
if (!prompt.isAtPrompt) return false;
if (alignedTyped?.trim() === command.trim()) return true;
if (reconcilePromptWithExternalCommand(prompt, command)) return true;
const liveCommand = prompt.userInput.trim();
if (liveCommand.length === 0) {
return !isNonPromptLine(`${prompt.promptText}${command.trim()}`);
}
return liveCommand === command.trim();
};
export const recordTerminalCommandExecution = (
command: string,
ctx: TerminalCommandExecutionContext,
term?: XTerm | null,
) => {
const cmd = command.trim();
if (cmd) {
if (cmd && shouldRecordShellHistory(cmd, term)) {
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, command);
};

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
clearPasteResidualAfterTerminalWrite,
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
prepareTerminalDataForUserPasteDisplay,
shouldBroadcastTerminalUserInput,
@@ -151,6 +152,95 @@ test("broadcast gate consumes paste state even when broadcast is disabled before
);
});
test("broadcast gate suppresses expected terminal cursor position report replies", () => {
const term = {};
markExpectedTerminalCursorPositionReport(term);
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
false,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
});
test("broadcast gate suppresses cursor position report replies split across chunks", () => {
const term = {};
markExpectedTerminalCursorPositionReport(term);
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
false,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "24;", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
false,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "80R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
false,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
});
test("broadcast gate preserves normal input while a cursor position report is pending", () => {
const term = {};
markExpectedTerminalCursorPositionReport(term);
assert.equal(
shouldBroadcastTerminalUserInput(term, "ls\r", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
false,
);
});
test("broadcast gate preserves keyboard sequences that look like cursor reports without a terminal request", () => {
const term = {};
assert.equal(
shouldBroadcastTerminalUserInput(term, "\x1b[1;2R", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
});
test("user paste preserves the existing scroll-on-paste behavior", () => {
const calls: string[] = [];
const term = {

View File

@@ -29,12 +29,20 @@ type PasteInputScrollState = {
remainingDataVariants: string[];
};
type TerminalProtocolReplyState = {
expiresAt: number;
pendingCursorPositionReports: number;
cursorPositionReportFragment: string;
};
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
const terminalProtocolReplyStates = new WeakMap<object, TerminalProtocolReplyState>();
const LONG_PASTE_MIN_LENGTH = 200;
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
const TERMINAL_PROTOCOL_REPLY_WINDOW_MS = 4000;
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
const BRACKETED_PASTE_START = "\x1b[200~";
@@ -116,6 +124,45 @@ const getPlainTerminalText = (data: string): string =>
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
);
type CursorPositionReportMatch =
| { type: "complete"; length: number }
| { type: "prefix" }
| { type: "none" };
const isAsciiDigit = (char: string): boolean => char >= "0" && char <= "9";
const matchCursorPositionReportFromStart = (data: string): CursorPositionReportMatch => {
if (!data.startsWith(ESC)) return { type: "none" };
if (data.length === 1) return { type: "prefix" };
if (data[1] !== "[") return { type: "none" };
if (data.length === 2) return { type: "prefix" };
let index = 2;
if (data[index] === "?") {
index += 1;
if (index === data.length) return { type: "prefix" };
}
let rowDigits = 0;
while (index < data.length && isAsciiDigit(data[index])) {
rowDigits += 1;
index += 1;
}
if (index === data.length) return { type: "prefix" };
if (rowDigits === 0 || data[index] !== ";") return { type: "none" };
index += 1;
let columnDigits = 0;
while (index < data.length && isAsciiDigit(data[index])) {
columnDigits += 1;
index += 1;
}
if (index === data.length) return { type: "prefix" };
if (columnDigits === 0 || data[index] !== "R") return { type: "none" };
return { type: "complete", length: index + 1 };
};
const getPasteEchoFragments = (text: string): string[] =>
Array.from(
new Set(
@@ -304,13 +351,81 @@ export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data:
return consumePasteInputState(pasteBroadcastStates, term, data);
}
export function markExpectedTerminalCursorPositionReport(term: object): void {
const currentState = terminalProtocolReplyStates.get(term);
const activeState = isStateActive(currentState)
? currentState
: {
expiresAt: 0,
pendingCursorPositionReports: 0,
cursorPositionReportFragment: "",
};
terminalProtocolReplyStates.set(term, {
expiresAt: getNow() + TERMINAL_PROTOCOL_REPLY_WINDOW_MS,
pendingCursorPositionReports: activeState.pendingCursorPositionReports + 1,
cursorPositionReportFragment: activeState.cursorPositionReportFragment,
});
}
function shouldSuppressTerminalProtocolReplyBroadcast(term: object, data: string): boolean {
const state = terminalProtocolReplyStates.get(term);
if (!isStateActive(state)) {
terminalProtocolReplyStates.delete(term);
return false;
}
if (state.pendingCursorPositionReports <= 0 || data.length === 0) {
return false;
}
let remainingData = `${state.cursorPositionReportFragment}${data}`;
let consumedCursorPositionReports = 0;
while (remainingData.length > 0) {
const match = matchCursorPositionReportFromStart(remainingData);
if (match.type === "none") {
state.cursorPositionReportFragment = "";
return false;
}
if (match.type === "prefix") {
if (consumedCursorPositionReports >= state.pendingCursorPositionReports) {
return false;
}
state.pendingCursorPositionReports -= consumedCursorPositionReports;
state.cursorPositionReportFragment = remainingData;
return true;
}
consumedCursorPositionReports += 1;
if (consumedCursorPositionReports > state.pendingCursorPositionReports) {
return false;
}
remainingData = remainingData.slice(match.length);
}
state.pendingCursorPositionReports -= consumedCursorPositionReports;
state.cursorPositionReportFragment = "";
if (state.pendingCursorPositionReports <= 0) {
terminalProtocolReplyStates.delete(term);
}
return true;
}
export function shouldBroadcastTerminalUserInput(
term: object,
data: string,
options: BroadcastUserInputOptions,
): boolean {
const isSuppressedUserPaste = shouldSuppressTerminalBroadcastForUserPaste(term, data);
return !isSuppressedUserPaste && !!options.isBroadcastEnabled && !!options.hasBroadcastInputHandler;
const isSuppressedTerminalProtocolReply = shouldSuppressTerminalProtocolReplyBroadcast(term, data);
return (
!isSuppressedUserPaste &&
!isSuppressedTerminalProtocolReply &&
!!options.isBroadcastEnabled &&
!!options.hasBroadcastInputHandler
);
}
function consumePasteInputState(

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createTerminalCwdTracker,
resolvePreferredTerminalCwd,
} from "./sftpCwd";
test("resolvePreferredTerminalCwd returns the renderer cwd without probing the backend", async () => {
let backendCalls = 0;
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: "/srv/app/current",
sessionId: "session-1",
getSessionPwd: async () => {
backendCalls += 1;
return { success: true, cwd: "/root" };
},
});
assert.equal(cwd, "/srv/app/current");
assert.equal(backendCalls, 0);
});
test("resolvePreferredTerminalCwd falls back to backend pwd when no renderer cwd is known", async () => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: undefined,
sessionId: "session-1",
getSessionPwd: async (sessionId) => {
assert.equal(sessionId, "session-1");
return { success: true, cwd: "/home/alice" };
},
});
assert.equal(cwd, "/home/alice");
});
test("resolvePreferredTerminalCwd returns null when neither source has a cwd", async () => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: "",
sessionId: "session-1",
getSessionPwd: async () => ({ success: false }),
});
assert.equal(cwd, null);
});
test("terminal cwd tracker clears stale renderer cwd before falling back to backend pwd", async () => {
const tracker = createTerminalCwdTracker();
tracker.setRendererCwd("/srv/old-session");
tracker.clearRendererCwd();
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: tracker.getRendererCwd(),
sessionId: "session-1",
getSessionPwd: async () => ({ success: true, cwd: "/home/fresh-session" }),
});
assert.equal(cwd, "/home/fresh-session");
});

View File

@@ -0,0 +1,53 @@
type SessionPwdResult = {
success: boolean;
cwd?: string | null;
};
type ResolvePreferredTerminalCwdOptions = {
rendererCwd?: string | null;
sessionId?: string | null;
getSessionPwd: (sessionId: string) => Promise<SessionPwdResult>;
};
const normalizeCwd = (cwd?: string | null): string | null => {
if (typeof cwd !== "string" || cwd.trim().length === 0) return null;
return cwd;
};
export type TerminalCwdTracker = {
getRendererCwd: () => string | undefined;
setRendererCwd: (cwd?: string | null) => string | undefined;
clearRendererCwd: () => void;
};
export const createTerminalCwdTracker = (): TerminalCwdTracker => {
let rendererCwd: string | undefined;
return {
getRendererCwd: () => rendererCwd,
setRendererCwd: (cwd) => {
rendererCwd = normalizeCwd(cwd) ?? undefined;
return rendererCwd;
},
clearRendererCwd: () => {
rendererCwd = undefined;
},
};
};
export const resolvePreferredTerminalCwd = async ({
rendererCwd,
sessionId,
getSessionPwd,
}: ResolvePreferredTerminalCwdOptions): Promise<string | null> => {
const knownCwd = normalizeCwd(rendererCwd);
if (knownCwd) return knownCwd;
if (!sessionId) return null;
try {
const result = await getSessionPwd(sessionId);
return result.success ? normalizeCwd(result.cwd) : null;
} catch {
return null;
}
};

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter";
import type { Snippet } from "../../domain/models";
const snip = (over: Partial<Snippet>): Snippet => ({
id: over.id ?? "s1",
label: over.label ?? "deploy",
command: over.command ?? "echo deploy",
...over,
});
test("matches by label prefix and carries the snippet + command preview", () => {
const s = snip({ id: "a", label: "deploy", command: "kubectl apply -f .\nkubectl rollout status deploy" });
const out = getSnippetSuggestions("dep", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].source, "snippet");
assert.equal(out[0].displayText, "deploy");
assert.equal(out[0].description, "kubectl apply -f .\nkubectl rollout status deploy");
assert.equal(out[0].snippet?.id, "a");
});
test("matches by command first line", () => {
const s = snip({ id: "b", label: "k8s", command: "kubectl get pods" });
const out = getSnippetSuggestions("kubectl", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].snippet?.id, "b");
});
test("is case-insensitive and prefix outranks substring", () => {
const a = snip({ id: "p", label: "Backup", command: "tar czf b.tgz ." });
const b = snip({ id: "q", label: "db-backup", command: "pg_dump" });
const out = getSnippetSuggestions("backup", [a, b], {});
assert.deepEqual(out.map((o) => o.snippet?.id), ["p", "q"]);
});
test("filters by host targets when set", () => {
const scoped = snip({ id: "t", label: "restart", command: "systemctl restart x", targets: ["host-2"] });
const global = snip({ id: "g", label: "restart-all", command: "echo all" });
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-1" }).map((o) => o.snippet?.id), ["g"]);
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
});
test("no match returns empty; empty input returns empty", () => {
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
});

View File

@@ -34,6 +34,9 @@ export const terminalLayerAreEqual = (
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.onToggleBroadcast === next.onToggleBroadcast &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.toggleSidePanelRef === next.toggleSidePanelRef &&
prev.identities === next.identities
);

View File

@@ -0,0 +1,68 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { ConnectionLog } from "./models.ts";
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
const baseLog: ConnectionLog = {
id: "log-base",
sessionId: "session-1",
hostId: "host-1",
hostLabel: "Example",
hostname: "example.com",
username: "user",
protocol: "ssh",
startTime: 1000,
localUsername: "local",
localHostname: "machine",
saved: false,
};
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
const staleLog = {
...baseLog,
id: "stale",
sessionId: "session-2",
startTime: 3000,
};
assert.equal(
selectConnectionLogForTerminalDataCapture(
[staleLog, matchingLog],
{ sessionId: "session-1", hostname: "example.com" },
)?.id,
"active",
);
});
test("selectConnectionLogForTerminalDataCapture reuses the latest log for repeated captures after reconnect", () => {
const firstCapture = {
...baseLog,
id: "first-capture",
startTime: 2000,
endTime: 2500,
terminalData: "first disconnect",
};
const olderSameSession = {
...baseLog,
id: "older-same-session",
startTime: 1500,
endTime: 1800,
terminalData: "older data",
};
const otherSession = {
...baseLog,
id: "other-session",
sessionId: "session-2",
startTime: 3000,
};
assert.equal(
selectConnectionLogForTerminalDataCapture(
[otherSession, olderSameSession, firstCapture],
{ sessionId: "session-1", hostname: "example.com" },
)?.id,
"first-capture",
);
});

25
domain/connectionLog.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { ConnectionLog } from "./models.ts";
interface TerminalDataCaptureTarget {
sessionId: string;
hostname?: string;
}
export const selectConnectionLogForTerminalDataCapture = (
connectionLogs: ConnectionLog[],
target: TerminalDataCaptureTarget,
): ConnectionLog | undefined => {
const matchingOpenLog = connectionLogs
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === target.sessionId;
return !!target.hostname && log.hostname === target.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
if (matchingOpenLog) return matchingOpenLog;
return connectionLogs
.filter((log) => log.sessionId === target.sessionId)
.sort((a, b) => b.startTime - a.startTime)[0];
};

View File

@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import {
detectVendorFromSshVersion,
normalizePrimaryTelnetState,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
sanitizeHost,
shouldProbeSessionCwd,
upsertHostById,
} from "./host.ts";
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
assert.equal(after.fontFamilyOverride, true);
});
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
assert.equal(detectVendorFromSshVersion("-"), "huawei");
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
});
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
true,
);
});
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
// Reconnect / manual deviceType='network': host.distro already says network.
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
false,
);
});
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
// isNetworkDevice is still false — the banner is the only signal (#1043).
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
false,
);
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
false,
);
});
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
test("resolveHostKeepalive falls back to global when override is not set", () => {

View File

@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim();
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (s === '-') return 'huawei';
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other';
};
/**
* Decide whether it is safe to run the post-connect `pwd` probe that
* discovers the session's working directory. The probe opens an extra exec
* channel running a POSIX-shell script; strict network-device CLIs such as
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
* skipped for them.
*
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
* explicit `deviceType: 'network'`). On a brand-new host that field is not
* populated yet, so we also inspect the SSH server identification banner —
* captured for free at handshake — which identifies most vendors directly.
*/
export const shouldProbeSessionCwd = (opts: {
isNetworkDevice: boolean;
remoteSshVersion?: string;
}): boolean =>
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {

View File

@@ -435,6 +435,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
// SFTP Operations
@@ -476,6 +477,7 @@ export interface TerminalSettings {
scrollback: number; // Number of lines kept in buffer
drawBoldInBrightColors: boolean; // Draw bold text in bright colors
terminalEmulationType: TerminalEmulationType; // Terminal emulation type (TERM env var)
startupCommandDelayMs: number; // Delay (ms) after connect before sending the startup command; also used between multiple lines
// Font
fontLigatures: boolean; // Enable font ligatures
@@ -493,6 +495,7 @@ export interface TerminalSettings {
// Keyboard
altAsMeta: boolean; // Use ⌥ as the Meta key
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
scrollOnInput: boolean; // Scroll terminal to bottom on input
scrollOnOutput: boolean; // Scroll terminal to bottom on output
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
@@ -683,6 +686,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: 10000,
drawBoldInBrightColors: true,
terminalEmulationType: 'xterm-256color',
startupCommandDelayMs: 600,
fontLigatures: true,
fontWeight: 400,
fontWeightBold: 700,
@@ -692,6 +696,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
cursorBlink: true,
minimumContrastRatio: 1,
altAsMeta: false,
optionArrowWordJump: false,
scrollOnInput: true,
scrollOnOutput: false,
scrollOnKeyPress: false,
@@ -720,7 +725,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
disableBracketedPaste: false, // Bracketed paste enabled by default
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
forcePromptNewLine: true, // Keep the next shell prompt visually separated from unterminated final output lines
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
autocompleteEnabled: true, // Autocomplete enabled by default

View File

@@ -192,6 +192,8 @@ export interface SyncPayload {
// Terminal
terminalTheme?: string;
followAppTerminalTheme?: boolean;
terminalThemeDark?: string;
terminalThemeLight?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;

View File

@@ -1,8 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
import type { TerminalTheme } from "./models";
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveFollowedTerminalThemeId,
TERMINAL_THEME_AUTO,
} from "./terminalAppearance";
import type { Host, TerminalTheme } from "./models";
const baseTheme: TerminalTheme = {
id: "ui-snow",
@@ -46,3 +51,173 @@ test("keeps terminal theme unchanged without a valid custom accent", () => {
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
});
const savedHost: Host = {
id: "host-1",
label: "Core switch",
hostname: "10.0.0.2",
username: "admin",
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
moshEnabled: true,
telnetEnabled: true,
telnetPort: 23,
};
test("terminal updates preserve saved connection protocol and port", () => {
const telnetSessionHost: Host = {
...savedHost,
protocol: "telnet",
port: 23,
moshEnabled: false,
fontFamily: "jetbrains-mono",
fontFamilyOverride: true,
};
const merged = mergeTerminalHostUpdate(savedHost, telnetSessionHost);
assert.equal(merged.protocol, "ssh");
assert.equal(merged.port, 22);
assert.equal(merged.moshEnabled, true);
assert.equal(merged.telnetEnabled, true);
assert.equal(merged.telnetPort, 23);
assert.equal(merged.fontFamily, "jetbrains-mono");
assert.equal(merged.fontFamilyOverride, true);
});
test("terminal updates still persist credentials entered during connection", () => {
const credentialUpdate: Host = {
...savedHost,
protocol: "telnet",
port: 23,
moshEnabled: false,
username: "deploy",
authMethod: "password",
password: "secret",
};
const merged = mergeTerminalHostUpdate(savedHost, credentialUpdate);
assert.equal(merged.protocol, "ssh");
assert.equal(merged.port, 22);
assert.equal(merged.moshEnabled, true);
assert.equal(merged.username, "deploy");
assert.equal(merged.authMethod, "password");
assert.equal(merged.password, "secret");
});
test("terminal updates still persist SFTP bookmarks", () => {
const bookmarkUpdate: Host = {
...savedHost,
protocol: "telnet",
port: 23,
moshEnabled: false,
sftpBookmarks: [{ id: "bookmark-1", path: "/srv/www", label: "/srv/www" }],
};
const merged = mergeTerminalHostUpdate(savedHost, bookmarkUpdate);
assert.equal(merged.protocol, "ssh");
assert.equal(merged.port, 22);
assert.equal(merged.moshEnabled, true);
assert.deepEqual(merged.sftpBookmarks, [
{ id: "bookmark-1", path: "/srv/www", label: "/srv/www" },
]);
});
test("terminal appearance reset clears only appearance fields", () => {
const hostWithAppearance: Host = {
...savedHost,
fontSize: 16,
fontSizeOverride: true,
};
const resetUpdate: Host = {
...hostWithAppearance,
protocol: "telnet",
port: 23,
moshEnabled: false,
fontSize: undefined,
fontSizeOverride: false,
};
const merged = mergeTerminalHostUpdate(hostWithAppearance, resetUpdate);
assert.equal(merged.protocol, "ssh");
assert.equal(merged.port, 22);
assert.equal(merged.moshEnabled, true);
assert.equal(merged.fontSize, undefined);
assert.equal(merged.fontSizeOverride, false);
});
test("follow-theme resolver: dark + auto follows the active dark UI preset", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"ui-midnight",
);
});
test("follow-theme resolver: light + auto follows the active light UI preset", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "light",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"ui-snow",
);
});
test("follow-theme resolver: explicit dark override wins over auto-matching", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: "dracula",
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"dracula",
);
});
test("follow-theme resolver: explicit light override wins over auto-matching", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "light",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: "solarized-light",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"solarized-light",
);
});
test("follow-theme resolver: auto with no UI match falls back to fallbackThemeId", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "no-such-ui-theme",
darkUiThemeId: "no-such-ui-theme",
fallbackThemeId: "netcatty-dark",
}),
"netcatty-dark",
);
});

View File

@@ -38,6 +38,25 @@ export const clearHostFontSizeOverride = (host: Host): Host => ({
fontSizeOverride: false,
});
export const mergeTerminalHostUpdate = (
savedHost: Host,
terminalHostUpdate: Host,
): Host => {
const nextHost: Host = {
...terminalHostUpdate,
id: savedHost.id,
protocol: savedHost.protocol,
port: savedHost.port,
moshEnabled: savedHost.moshEnabled,
};
if (!Object.prototype.hasOwnProperty.call(savedHost, 'protocol')) delete nextHost.protocol;
if (!Object.prototype.hasOwnProperty.call(savedHost, 'port')) delete nextHost.port;
if (!Object.prototype.hasOwnProperty.call(savedHost, 'moshEnabled')) delete nextHost.moshEnabled;
return nextHost;
};
export const resolveHostTerminalThemeId = (host: Host | null | undefined, defaultThemeId: string): string =>
hasHostThemeOverride(host) && host?.theme ? host.theme : defaultThemeId;
@@ -69,6 +88,39 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
/**
* Sentinel stored in the per-mode follow-theme settings meaning "let the
* terminal theme follow the active UI theme preset" (the legacy
* auto-matching behavior), as opposed to a concrete terminal theme id.
*/
export const TERMINAL_THEME_AUTO = 'auto';
/**
* Resolve which terminal theme id to use while "Follow Application Theme" is
* enabled, honoring the user's per-mode override.
*
* - A concrete theme id in the active mode's setting is used as-is.
* - `TERMINAL_THEME_AUTO` (the default) keeps the legacy behavior: match the
* active UI theme preset, then `fallbackThemeId` when no UI match exists.
*/
export const resolveFollowedTerminalThemeId = (args: {
resolvedTheme: 'light' | 'dark';
terminalThemeDarkId: string;
terminalThemeLightId: string;
lightUiThemeId: string;
darkUiThemeId: string;
fallbackThemeId: string;
}): string => {
const selected = args.resolvedTheme === 'dark'
? args.terminalThemeDarkId
: args.terminalThemeLightId;
if (selected && selected !== TERMINAL_THEME_AUTO) return selected;
const activeUiThemeId = args.resolvedTheme === 'dark'
? args.darkUiThemeId
: args.lightUiThemeId;
return getTerminalThemeForUiTheme(activeUiThemeId) ?? args.fallbackThemeId;
};
type ParsedHslToken = {
hue: number;
saturation: number;

View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeTerminalSettings } from "./models";
test("normalizeTerminalSettings disables prompt line breaks by default", () => {
const settings = normalizeTerminalSettings();
assert.equal(settings.forcePromptNewLine, false);
});
test("normalizeTerminalSettings defaults startupCommandDelayMs to 600", () => {
assert.equal(normalizeTerminalSettings().startupCommandDelayMs, 600);
});
test("normalizeTerminalSettings preserves a provided startupCommandDelayMs", () => {
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 0 }).startupCommandDelayMs, 0);
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 1500 }).startupCommandDelayMs, 1500);
});

View File

@@ -7,6 +7,11 @@ module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
// Give the macOS build a unique Mach-O LC_UUID before signing, so macOS
// Local Network privacy treats Netcatty distinctly from every other
// Electron app (which all share Electron's prebuilt LC_UUID) — see #1040
// and scripts/afterPackMacUuid.cjs. No-op on Windows/Linux.
afterPack: './scripts/afterPackMacUuid.cjs',
// Platform-split icons (#813):
// - public/icon.png keeps Apple's HIG grid margin so the rendered
// squircle sits at ~88% of the PNG canvas. macOS needs this —

View File

@@ -0,0 +1,50 @@
"use strict";
/**
* Claude Code auth/config detection helpers (main process).
*
* claude-agent-acp authenticates from env (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN)
* or from credentials stored under CLAUDE_CONFIG_DIR (default ~/.claude). We use
* this to turn opaque "-32603 Internal error" failures into an actionable message
* when no auth is configured. NOTE: macOS may store credentials in the Keychain
* rather than a file, so 'none' is a heuristic — callers must NOT hard-block on it;
* only use it to improve the error message after an actual failure.
*/
const { existsSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* Expand a leading "~" to the user's home directory. Env vars handed to a
* child process are NOT shell-expanded, so "~/.claude" would otherwise be
* treated as a literal directory named "~". Only a leading "~", "~/" or "~\"
* is expanded (not "~user"); other values pass through unchanged.
*/
function expandHomePath(p) {
if (typeof p !== "string") return p;
const trimmed = p.trim();
if (trimmed === "~") return os.homedir();
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return p;
}
function getClaudeConfigDir(env) {
const custom = typeof env?.CLAUDE_CONFIG_DIR === "string" ? env.CLAUDE_CONFIG_DIR.trim() : "";
return custom ? expandHomePath(custom) : path.join(os.homedir(), ".claude");
}
/**
* @returns {'env'|'credentials-file'|'none'}
*/
function detectClaudeAuthPresence(env, fileExists = existsSync) {
const apiKey = typeof env?.ANTHROPIC_API_KEY === "string" ? env.ANTHROPIC_API_KEY.trim() : "";
const authToken = typeof env?.ANTHROPIC_AUTH_TOKEN === "string" ? env.ANTHROPIC_AUTH_TOKEN.trim() : "";
if (apiKey || authToken) return "env";
if (fileExists(path.join(getClaudeConfigDir(env), ".credentials.json"))) return "credentials-file";
return "none";
}
module.exports = { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath };

View File

@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath } = require("./claudeAuth.cjs");
test("getClaudeConfigDir: defaults to ~/.claude", () => {
assert.equal(getClaudeConfigDir({}), path.join(os.homedir(), ".claude"));
});
test("getClaudeConfigDir: honors CLAUDE_CONFIG_DIR", () => {
assert.equal(getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "/custom/dir" }), "/custom/dir");
});
test("getClaudeConfigDir: expands a leading ~ in CLAUDE_CONFIG_DIR", () => {
assert.equal(
getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "~/.claude-work" }),
path.join(os.homedir(), ".claude-work"),
);
});
test("expandHomePath: expands '~' and '~/...', leaves others unchanged", () => {
assert.equal(expandHomePath("~"), os.homedir());
assert.equal(expandHomePath("~/x/y"), path.join(os.homedir(), "x/y"));
assert.equal(expandHomePath("/abs/path"), "/abs/path");
assert.equal(expandHomePath("~user/x"), "~user/x");
assert.equal(expandHomePath(""), "");
});
test("detectClaudeAuthPresence: ANTHROPIC_API_KEY in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: "sk-x" }, () => false), "env");
});
test("detectClaudeAuthPresence: ANTHROPIC_AUTH_TOKEN in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_AUTH_TOKEN: "tok" }, () => false), "env");
});
test("detectClaudeAuthPresence: blank env token is ignored", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: " " }, () => false), "none");
});
test("detectClaudeAuthPresence: credentials file under config dir => 'credentials-file'", () => {
const seen = [];
const result = detectClaudeAuthPresence(
{ CLAUDE_CONFIG_DIR: "/custom/dir" },
(p) => { seen.push(p); return p === path.join("/custom/dir", ".credentials.json"); },
);
assert.equal(result, "credentials-file");
assert.ok(seen.includes(path.join("/custom/dir", ".credentials.json")));
});
test("detectClaudeAuthPresence: nothing => 'none'", () => {
assert.equal(detectClaudeAuthPresence({}, () => false), "none");
});

View File

@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
return "";
}
// bash and csh/tcsh print a banner to the terminal right before exiting due to
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
// code, no signal — so it is indistinguishable from a user-typed `exit` by
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
// reliable discriminator, letting the SSH bridge keep the tab open for
// reconnect instead of auto-closing it (#1062, regression of #977).
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
function looksLikeIdleAutoLogout(outputTail) {
if (typeof outputTail !== "string" || !outputTail) return false;
// The shell prints this banner on its own line as the very last thing before
// it exits, so anchor on the final non-empty line rather than a loose
// substring. Otherwise unrelated output that merely mentions "auto-logout"
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
// would be misclassified as a timeout and wrongly keep the tab open.
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
if (!line) continue;
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
}
return false;
}
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
@@ -399,6 +425,7 @@ module.exports = {
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
trackSessionIdlePrompt,
looksLikeIdleAutoLogout,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,

View File

@@ -7,6 +7,7 @@ const {
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
looksLikeIdleAutoLogout,
prepareCommandForSpawn,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
// with the cached PS line, so downstream wrapper selection sees "".
assert.equal(getFreshIdlePrompt(session), "");
});
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
// bash prints this immediately before a TMOUT auto-logout exit. The exit
// itself is a clean shell exit (code 0, no signal), so the banner is the
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
assert.equal(
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
});
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
assert.equal(
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
// and must still auto-close the tab.
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
});
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
// "auto-logout" scrolled past long ago; the user then ran more commands and
// exited normally. Only the tail end is inspected, so this is not a timeout.
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
// Investigating TMOUT: the user greps the profile (output mentions
// "auto-logout"), reads it, then exits on purpose. The banner is not the
// final line, so the tab must still auto-close. Guards against matching an
// unanchored substring anywhere in the recent output.
const tail =
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
"root@h:~# exit\r\nlogout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
// The banner can share a line with the trailing prompt after ANSI/control
// bytes are stripped (observed over real SSH); anchoring on the line end
// must still match.
const tail =
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
assert.equal(looksLikeIdleAutoLogout(tail), true);
});
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
assert.equal(looksLikeIdleAutoLogout(""), false);
assert.equal(looksLikeIdleAutoLogout(undefined), false);
assert.equal(looksLikeIdleAutoLogout(null), false);
});

View File

@@ -37,6 +37,11 @@ const {
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
const { detectClaudeAuthPresence, expandHomePath } = require("./ai/claudeAuth.cjs");
const CLAUDE_AUTH_HELP_MESSAGE =
"Claude Code has no usable authentication. Open Settings -> AI -> Claude Code and set a Config directory (point it at a folder where you've run `claude` login) or add an ANTHROPIC_API_KEY under Environment variables. Alternatively, run `claude` in a terminal to log in.";
const {
codexLoginSessions,
resolveCodexAcpBinaryPath,
@@ -552,6 +557,22 @@ function envPairsToObject(entries) {
return result;
}
function normalizeAgentEnv(env) {
if (!env || typeof env !== "object" || Array.isArray(env)) return {};
const result = {};
for (const [key, value] of Object.entries(env)) {
if (!key || value == null) continue;
result[key] = String(value);
}
// CLAUDE_CONFIG_DIR is consumed as a filesystem path by the spawned agent,
// which won't shell-expand "~". Expand it here so "~/.claude" works and the
// stored value stays portable (each device expands to its own home).
if (result.CLAUDE_CONFIG_DIR) {
result.CLAUDE_CONFIG_DIR = expandHomePath(result.CLAUDE_CONFIG_DIR);
}
return result;
}
function mapMcpServerToCopilotConfig(server) {
if (!server || typeof server !== "object" || !server.name) return null;
@@ -1481,15 +1502,8 @@ function registerHandlers(ipcMain) {
return hasCodexAcpUsage && rejectedVersionFlag;
}
function isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe) {
return command === "claude" && usesAcpFallback && probe?.launched && probe.exitCode === 0;
}
function isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
return (
isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) ||
isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe)
);
return isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
}
async function runCodexCli(args, options) {
@@ -1691,7 +1705,7 @@ function registerHandlers(ipcMain) {
}
}
// Discover external agents from PATH, plus bundled ACP binaries if present.
// Discover external agents from PATH. Codex can additionally use a bundled ACP fallback.
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const agents = [];
@@ -1704,7 +1718,6 @@ function registerHandlers(ipcMain) {
acpCommand: "claude-agent-acp",
acpArgs: [],
args: ["-p", "--output-format", "text", "{prompt}"],
resolveAcp: resolveClaudeAcpBinaryPath,
},
{
command: "codex",
@@ -1732,12 +1745,11 @@ function registerHandlers(ipcMain) {
for (const agent of knownAgents) {
let resolvedPath = resolveCliFromPath(agent.command, shellEnv);
const supportsBundledAcpFallback = agent.command === "codex";
// If the base command is not on PATH, check whether the bundled ACP
// binary is available — the agent can still work via ACP without the
// standalone CLI installed.
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
// resolveCodexAcpBinaryPath returns a plain string.
// Codex can still work via bundled ACP if its standalone CLI is missing.
// Claude must resolve to the system `claude` executable and pass version probing.
// ACP resolvers return either a plain path or { command, prependArgs }.
let versionCommand = null;
let versionPrependArgs = [];
let usesAcpFallback = false;
@@ -1773,7 +1785,7 @@ function registerHandlers(ipcMain) {
}
return false;
};
if (!resolvedPath) {
if (!resolvedPath && supportsBundledAcpFallback) {
tryResolveAcpFallback();
}
@@ -1793,7 +1805,7 @@ function registerHandlers(ipcMain) {
probe,
);
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && agent.command === "codex") {
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && supportsBundledAcpFallback) {
const previousPath = resolvedPath;
if (tryResolveAcpFallback() && resolvedPath !== previousPath && !seenPaths.has(resolvedPath)) {
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
@@ -1844,18 +1856,6 @@ function registerHandlers(ipcMain) {
}
return null;
}
if (command === "claude") {
const acpPath = resolveClaudeAcpBinaryPath(shellEnv, electronModule);
const scriptPath = acpPath?.prependArgs?.[0];
const displayPath = scriptPath || acpPath?.command;
if (displayPath && displayPath !== "claude-agent-acp" && existsSync(displayPath)) {
return {
displayPath,
command: scriptPath ? acpPath.command : null,
prependArgs: scriptPath ? acpPath.prependArgs : [],
};
}
}
return null;
};
const resolveBundledAcpFallback = () => {
@@ -2121,7 +2121,7 @@ function registerHandlers(ipcMain) {
// Known agent command names (must match knownAgents in discover handler)
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-agent-acp",
"claude",
"codex", "codex-acp",
"copilot",
]);
@@ -2343,7 +2343,7 @@ function registerHandlers(ipcMain) {
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId, agentEnv: requestedAgentEnv }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
@@ -2375,7 +2375,7 @@ function registerHandlers(ipcMain) {
}
}
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
@@ -2456,12 +2456,15 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext, agentEnv: requestedAgentEnv }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let abortController = null;
// Hoisted so the catch block can reference them for Claude-specific error handling.
let isClaudeAgent = false;
let claudeAuthPresence = null;
try {
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
@@ -2514,8 +2517,14 @@ function registerHandlers(ipcMain) {
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
// For Claude: detect whether any auth is reachable so we can turn an
// opaque "-32603 Internal error" into actionable guidance on failure.
// Heuristic only (macOS may keep creds in Keychain) — never hard-block.
claudeAuthPresence = isClaudeAgent
? detectClaudeAuthPresence({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) })
: null;
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const effectiveToolIntegrationMode = normalizeToolIntegrationMode(toolIntegrationMode);
debugMcpLog("ACP request start", {
@@ -2579,7 +2588,13 @@ function registerHandlers(ipcMain) {
const authFingerprint = isCodexAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
: isClaudeAgent
// Fingerprint the Claude agent env (config dir + user env vars) so a
// settings change invalidates the cached per-session provider and the
// next turn respawns with the new config instead of reusing a stale
// process spawned with the old env.
? JSON.stringify(normalizeAgentEnv(requestedAgentEnv))
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -2684,7 +2699,7 @@ function registerHandlers(ipcMain) {
);
cleanupAcpProvider(chatSessionId);
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
@@ -2808,7 +2823,9 @@ function registerHandlers(ipcMain) {
: acpArgs || [],
env: (() => {
const fallbackEnv = withCliDiscoveryEnv(
isCodexAgent && apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
isCodexAgent && apiKey
? { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv), CODEX_API_KEY: apiKey }
: { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) },
);
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
@@ -3018,11 +3035,18 @@ function registerHandlers(ipcMain) {
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
if (isClaudeAgent) {
// Reap the persistent agent process so a failed turn doesn't leak
// node.exe processes (provider uses persistSession:true).
cleanupAcpProvider(chatSessionId);
}
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: isCodexAgent
? "Codex returned an empty response. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key."
: "Agent returned an empty response.",
: isClaudeAgent && claudeAuthPresence === "none"
? CLAUDE_AUTH_HELP_MESSAGE
: "Agent returned an empty response.",
});
} else {
// Clear replay fallback when the recovered turn either streamed
@@ -3049,19 +3073,40 @@ function registerHandlers(ipcMain) {
} catch (err) {
console.error("[ACP] Handler caught error:", err?.message || err, err?.stack?.split("\n").slice(0, 3).join("\n"));
const normalized = extractCodexError(err);
const errMsg = normalized.message;
// #3 (light): include JSON-RPC code/data when present so Claude's bare
// "Internal error" isn't shown context-free.
const errCode = typeof err?.code === "number" ? err.code : err?.data?.code;
// Only surface data fields we don't already show (message/code) so the
// detail doesn't echo them back.
let errDetail = "";
if (err?.data && typeof err.data === "object") {
const extra = { ...err.data };
delete extra.code;
delete extra.message;
if (Object.keys(extra).length > 0) {
try { errDetail = JSON.stringify(extra); } catch { errDetail = ""; }
}
}
const errMsg = [normalized.message, errCode != null ? `(code ${errCode})` : "", errDetail]
.filter(Boolean)
.join(" ");
const isAuthErr = isCodexAuthError(normalized);
if (isAuthErr) {
console.error("[ACP] Auth error — user needs to re-login:", errMsg);
cleanupAcpProvider(chatSessionId);
} else if (isClaudeAgent) {
// #4: always reap the Claude provider/process tree on error.
cleanupAcpProvider(chatSessionId);
}
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: isAuthErr
? `Authentication failed. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key.\n\nDetails: ${errMsg}`
: errMsg,
: isClaudeAgent && claudeAuthPresence === "none"
? `${CLAUDE_AUTH_HELP_MESSAGE}\n\nDetails: ${errMsg}`
: errMsg,
});
} finally {
acpActiveStreams.delete(requestId);

View File

@@ -65,6 +65,32 @@ function writeFakeCodexAcpLoaderError(filePath) {
fs.chmodSync(filePath, 0o755);
}
function writeFakeBrokenClaudeCli(filePath) {
if (process.platform === "win32") {
fs.writeFileSync(
filePath,
"@echo off\r\necho file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95\r\nexit /b 1\r\n",
"utf8",
);
return;
}
fs.writeFileSync(
filePath,
"#!/bin/sh\necho 'file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95'\nexit 1\n",
"utf8",
);
fs.chmodSync(filePath, 0o755);
}
function writeFakeClaudeVersion(filePath, version = "2.1.145 (Claude Code)") {
if (process.platform === "win32") {
fs.writeFileSync(filePath, `@echo off\r\necho ${version}\r\n`, "utf8");
return;
}
fs.writeFileSync(filePath, `#!/bin/sh\necho '${version}'\n`, "utf8");
fs.chmodSync(filePath, 0o755);
}
function loadBridgeWithMocks(options = {}) {
const streamCalls = [];
const safeSendCalls = [];
@@ -872,21 +898,12 @@ test("resolve-cli rejects bundled Codex ACP fallback when the fallback prints a
}
});
test("discovers bundled Claude ACP fallback when the version probe is silent", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-discover-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
test("does not discover Claude without a system Claude CLI", async () => {
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
resolveClaudeAcpBinaryPath: () => {
throw new Error("Claude ACP resolver should not be used for discovery");
},
});
const ipcMain = createIpcMainStub();
@@ -903,17 +920,88 @@ test("discovers bundled Claude ACP fallback when the version probe is silent", a
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "claude");
assert.equal(agents[0].path, scriptPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("resolve-cli accepts stored bundled Claude ACP script path via its launcher", async (t) => {
test("does not discover Claude when the PATH Claude shim is broken", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
writeFakeBrokenClaudeCli(claudePath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
resolveClaudeAcpBinaryPath: () => {
throw new Error("Claude ACP resolver should not be used for discovery");
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("resolve-cli detects PATH Claude and reads its version", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-resolve-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
writeFakeClaudeVersion(claudePath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").includes("Claude Code"),
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: "2.1.145 (Claude Code)",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli rejects stored Claude adapter script paths", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-stored-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
@@ -925,10 +1013,9 @@ test("resolve-cli accepts stored bundled Claude ACP script path via its launcher
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
normalizeCliPathForPlatform: () => scriptPath,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
resolveClaudeAcpBinaryPath: () => {
throw new Error("Claude ACP resolver should not be used for path resolution");
},
});
const ipcMain = createIpcMainStub();
@@ -947,14 +1034,121 @@ test("resolve-cli accepts stored bundled Claude ACP script path via its launcher
assert.deepEqual(result, {
path: scriptPath,
version: "Bundled ACP",
available: true,
version: null,
available: false,
});
} finally {
restore();
}
});
test("resolve-cli rejects broken PATH Claude shims", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-resolve-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
writeFakeBrokenClaudeCli(claudePath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
resolveClaudeAcpBinaryPath: () => {
throw new Error("Claude ACP resolver should not be used for path resolution");
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: null,
available: false,
});
} finally {
restore();
}
});
test("ACP stream passes the configured system Claude executable to claude-agent-acp", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-executable-env-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
const { bridge, providerCreationArgs, restore } = loadBridgeWithMocks({
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
createACPProvider: () => ({
tools: {},
languageModel() {
return { id: "fake-model" };
},
async initSession() {},
getSessionId() {
return "claude-session";
},
cleanup() {},
}),
streamText: () => createEmptyStreamResult(),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
assert.equal(typeof streamHandler, "function");
await streamHandler({ sender: { id: 1 } }, {
requestId: "req-claude-env",
chatSessionId: "chat-claude-env",
acpCommand: "claude-agent-acp",
acpArgs: [],
prompt: "hello",
providerId: undefined,
model: undefined,
existingSessionId: undefined,
historyMessages: [],
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
agentEnv: { CLAUDE_CODE_EXECUTABLE: "/opt/homebrew/bin/claude" },
});
assert.equal(
providerCreationArgs[0].env.CLAUDE_CODE_EXECUTABLE,
"/opt/homebrew/bin/claude",
);
} finally {
restore();
}
});
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
const ipcMain = createIpcMainStub();

View File

@@ -0,0 +1,83 @@
/**
* BoringSSL Diffie-Hellman group compatibility shim.
*
* Electron ships with BoringSSL, which no longer exposes some standard MODP
* groups through the *named* `crypto.createDiffieHellmanGroup()` API — notably
* the 1024-bit Oakley Group 2 ("modp2") that backs the SSH
* `diffie-hellman-group1-sha1` key exchange. ssh2 calls
* `createDiffieHellmanGroup('modp2')` for that kex, so on Electron it throws
* "Unknown DH group" and legacy network devices that only speak group1-sha1
* cannot be reached (issue #1035).
*
* The underlying DH math still works on BoringSSL via `createDiffieHellman()`
* with an explicit prime, so this shim wraps `createDiffieHellmanGroup` to fall
* back to the well-known prime constants when (and only when) the runtime can't
* resolve a group by name. On OpenSSL builds the original call succeeds and the
* fallback is never used, so behavior is unchanged there.
*
* IMPORTANT: ssh2 destructures `createDiffieHellmanGroup` at module load, so this
* must be installed BEFORE ssh2 (or any bridge that requires it) is loaded.
*/
const crypto = require("node:crypto");
// Standard MODP groups (RFC 2409 / RFC 3526), generator 2. These primes are
// public constants and are byte-identical to Node's built-in groups, so the
// fallback produces the exact same key exchange the named group would have.
// Only groups that a runtime might drop yet ssh2 still requests need to live
// here; modp14/16/18 remain available on BoringSSL so they are intentionally
// omitted.
const MODP_GROUP_PRIMES = {
// Oakley Group 2 — RFC 2409, 1024-bit. ssh2: diffie-hellman-group1-sha1.
modp2:
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" +
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" +
"FFFFFFFFFFFFFFFF",
};
const MODP_GENERATOR = Buffer.from([0x02]);
function createGroupFromPrime(name) {
const primeHex = MODP_GROUP_PRIMES[name];
if (!primeHex) return null;
return crypto.createDiffieHellman(Buffer.from(primeHex, "hex"), MODP_GENERATOR);
}
/**
* Wrap `target.createDiffieHellmanGroup` so missing named groups fall back to an
* explicit-prime DiffieHellman. Idempotent. Returns true if it installed the
* shim, false if it was already installed (or there was nothing to wrap).
* @param {{ createDiffieHellmanGroup?: Function }} [target] defaults to the crypto module
*/
function installBoringSslDhCompat(target = crypto) {
const original = target.createDiffieHellmanGroup;
if (typeof original !== "function" || original.__boringSslDhCompat) {
return false;
}
const wrapped = function createDiffieHellmanGroup(name) {
try {
return original(name);
} catch (err) {
const fallback = createGroupFromPrime(name);
if (!fallback) throw err;
return fallback;
}
};
wrapped.__boringSslDhCompat = true;
try {
target.createDiffieHellmanGroup = wrapped;
} catch {
// The property may be read-only on some runtimes; force it via defineProperty.
Object.defineProperty(target, "createDiffieHellmanGroup", {
value: wrapped,
configurable: true,
writable: true,
});
}
return true;
}
module.exports = { installBoringSslDhCompat, createGroupFromPrime, MODP_GROUP_PRIMES };

View File

@@ -0,0 +1,76 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const {
installBoringSslDhCompat,
MODP_GROUP_PRIMES,
} = require("./boringSslDhCompat.cjs");
test("falls back to an explicit-prime DH when the runtime lacks a named group", () => {
// Simulate BoringSSL: the named lookup throws "Unknown DH group".
const target = {
createDiffieHellmanGroup(name) {
throw new Error(`Unknown DH group: ${name}`);
},
};
assert.equal(installBoringSslDhCompat(target), true);
const dh = target.createDiffieHellmanGroup("modp2");
// The fallback group uses the exact RFC 2409 group1 prime.
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
// And it performs a real, correct DH exchange.
const peer = crypto.createDiffieHellman(
Buffer.from(MODP_GROUP_PRIMES.modp2, "hex"),
Buffer.from([2]),
);
const ourPublic = dh.generateKeys();
const peerPublic = peer.generateKeys();
assert.ok(dh.computeSecret(peerPublic).equals(peer.computeSecret(ourPublic)));
});
test("uses the runtime's group when the name resolves (no fallback)", () => {
let calls = 0;
const sentinel = Symbol("native-group");
const target = {
createDiffieHellmanGroup() {
calls += 1;
return sentinel;
},
};
installBoringSslDhCompat(target);
assert.equal(target.createDiffieHellmanGroup("modp2"), sentinel);
assert.equal(calls, 1);
});
test("rethrows the original error for groups it cannot back", () => {
const target = {
createDiffieHellmanGroup() {
throw new Error("Unknown DH group");
},
};
installBoringSslDhCompat(target);
assert.throws(() => target.createDiffieHellmanGroup("modp-nonexistent"), /Unknown DH group/);
});
test("install is idempotent", () => {
const target = {
createDiffieHellmanGroup() {
return null;
},
};
assert.equal(installBoringSslDhCompat(target), true);
assert.equal(installBoringSslDhCompat(target), false);
});
test("on this (OpenSSL) runtime the real modp2 still works through the shim", () => {
// Sanity check against the actual crypto module: installing must not break the
// normal path where the runtime resolves the group by name.
const localCrypto = require("node:crypto");
installBoringSslDhCompat(localCrypto);
const dh = localCrypto.createDiffieHellmanGroup("modp2");
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
});

View File

@@ -98,6 +98,8 @@ const buildS3Client = (config) =>
region: config.region,
endpoint: normalizeEndpoint(config.endpoint),
forcePathStyle: config.forcePathStyle ?? true,
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
@@ -299,4 +301,5 @@ module.exports = {
// Exposed for tests
handleWebdavInitialize,
buildBasicAuthHeader,
buildS3Client,
};

View File

@@ -0,0 +1,25 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildS3Client,
} = require("./cloudSyncBridge.cjs");
const config = {
endpoint: "https://s3.example.com",
region: "us-east-1",
bucket: "netcatty-test",
accessKeyId: "access",
secretAccessKey: "secret",
forcePathStyle: true,
};
test("S3 client only sends request checksums when required", async () => {
const client = buildS3Client(config);
assert.equal(await client.config.requestChecksumCalculation(), "WHEN_REQUIRED");
});
test("S3 client only validates response checksums when required", async () => {
const client = buildS3Client(config);
assert.equal(await client.config.responseChecksumValidation(), "WHEN_REQUIRED");
});

View File

@@ -0,0 +1,63 @@
"use strict";
/**
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
*
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
* adds that whole interval as latency to interactive echo — every keystroke
* round-trips through the buffer and waits out the timer before it can paint.
* Turn-based flushing coalesces only the data that has already arrived in the
* current turn, so a single echoed keystroke is forwarded almost immediately
* while bursts of output still collapse into one send.
*
* A byte cap still forces an immediate, synchronous flush so a flood of output
* can't grow the buffer without bound between turns.
*
* @param {(data: string) => void} sendFn delivers an accumulated batch
* @param {{ maxBufferSize?: number }} [options]
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
*/
function createPtyOutputBuffer(sendFn, options = {}) {
const maxBufferSize = options.maxBufferSize ?? 16384; // 16KB
let dataBuffer = "";
let scheduled = null;
const cancelScheduled = () => {
if (scheduled) {
clearImmediate(scheduled);
scheduled = null;
}
};
const flushNow = () => {
scheduled = null;
if (dataBuffer.length > 0) {
const pending = dataBuffer;
dataBuffer = "";
sendFn(pending);
}
};
const bufferData = (data) => {
dataBuffer += data;
if (dataBuffer.length >= maxBufferSize) {
// Large enough to ship right now — don't wait for the turn flush.
cancelScheduled();
flushNow();
} else if (!scheduled) {
scheduled = setImmediate(flushNow);
}
};
const flush = () => {
cancelScheduled();
flushNow();
};
return { bufferData, flush };
}
module.exports = { createPtyOutputBuffer };

View File

@@ -0,0 +1,90 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
/** Resolve after one event-loop turn (immediates have run). */
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("coalesces data buffered within the same turn into a single send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("a");
buffer.bufferData("b");
buffer.bufferData("c");
// Nothing is sent synchronously while still in the same turn.
assert.equal(sends.length, 0);
await tick();
assert.deepEqual(sends, ["abc"]);
});
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("x");
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
// immediate turn. Turn-based flushing must have delivered it by now.
await tick();
assert.deepEqual(sends, ["x"]);
});
test("flushes immediately and synchronously once the size cap is reached", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
});
buffer.bufferData("ab");
assert.equal(sends.length, 0); // under cap, still pending
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
// Cap flush happens synchronously, without waiting for the turn.
assert.deepEqual(sends, ["abcd"]);
// The pending turn flush must have been cancelled — no empty/duplicate send.
await tick();
assert.deepEqual(sends, ["abcd"]);
});
test("flush() forces a synchronous send and cancels the pending turn", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("hello");
buffer.flush();
assert.deepEqual(sends, ["hello"]);
await tick();
assert.deepEqual(sends, ["hello"]); // not sent twice
});
test("flush() with an empty buffer does not send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.flush();
assert.equal(sends.length, 0);
});
test("keeps batching after a flush", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("first");
await tick();
buffer.bufferData("second");
await tick();
assert.deepEqual(sends, ["first", "second"]);
});

View File

@@ -36,6 +36,10 @@ const {
preparePrivateKeyForAuth,
loadFirstIdentityFileForAuth,
} = require("./sshAuthHelper.cjs");
const {
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
} = require("./sshAlgorithms.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
@@ -481,44 +485,6 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
return true;
};
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
const { safeSend } = require("./ipcUtils.cjs");
/**
@@ -2471,6 +2437,8 @@ module.exports = {
init,
registerHandlers,
getSftpClients,
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
requireSftpChannel,
encodePathForSession,
ensureRemoteDirForSession,

View File

@@ -0,0 +1,157 @@
const crypto = require("node:crypto");
const FIXED_DH_GROUP_BY_KEX = Object.freeze({
"diffie-hellman-group1-sha1": "modp2",
"diffie-hellman-group14-sha1": "modp14",
"diffie-hellman-group14-sha256": "modp14",
"diffie-hellman-group16-sha512": "modp16",
"diffie-hellman-group18-sha512": "modp18",
});
let _md5Supported = null;
const dhGroupSupport = new Map();
// MODP groups that every SSH runtime we target supports, so we skip the
// feature-detection probe for them. Under Electron's BoringSSL, instantiating a
// fixed DH group object purely to test support is pathologically slow — the
// 8192-bit modp18 alone takes ~20s on first call, freezing the first connection
// of every app launch — yet the probe always succeeds. We only feature-detect
// groups a runtime might genuinely drop (e.g. BoringSSL removed the weak
// 1024-bit group1/modp2); those fail their probe instantly, so it stays cheap.
const ASSUMED_SUPPORTED_DH_GROUPS = new Set(["modp14", "modp16", "modp18"]);
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
// algorithm list can skip hmac-md5 on those builds; ssh2 validates exact
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
// before the SSH handshake even starts.
function md5Supported() {
if (_md5Supported === null) {
try { _md5Supported = crypto.getHashes().includes("md5"); }
catch { _md5Supported = false; }
}
return _md5Supported;
}
function fixedDhGroupSupported(groupName) {
if (ASSUMED_SUPPORTED_DH_GROUPS.has(groupName)) return true;
if (!dhGroupSupport.has(groupName)) {
try {
crypto.createDiffieHellmanGroup(groupName);
dhGroupSupport.set(groupName, true);
} catch {
dhGroupSupport.set(groupName, false);
}
}
return dhGroupSupport.get(groupName);
}
function filterSupportedFixedDhKex(kexAlgorithms) {
return kexAlgorithms.filter((kexName) => {
const groupName = FIXED_DH_GROUP_BY_KEX[kexName];
return !groupName || fixedDhGroupSupported(groupName);
});
}
function buildBaseAlgorithms() {
return {
cipher: [
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
],
kex: filterSupportedFixedDhKex([
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256",
"diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512",
"diffie-hellman-group-exchange-sha256",
]),
compress: ["none"],
};
}
function applyLegacyAlgorithms(algorithms) {
algorithms.kex.push(...filterSupportedFixedDhKex([
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha1",
]));
algorithms.cipher.push(
"aes128-cbc", "aes256-cbc", "3des-cbc",
);
algorithms.serverHostKey = [
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
];
}
function applyLegacyHmacAlgorithms(algorithms) {
// Legacy HMACs: required by very old servers (e.g. FreeBSD 6.1 OpenSSH
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
// handshake exchange-hash MAC never agrees and the host-key signature
// verification that depends on it fails with
// "Handshake failed: signature verification failed", which looks like
// a host-key problem but is really a MAC negotiation mismatch.
//
// hmac-md5 is only appended when the local OpenSSL build actually
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
// ssh2 strictly validates exact algorithm lists. Listing an unavailable
// algorithm would throw "Unsupported algorithm" before any SSH
// negotiation, turning the legacy toggle into a hard failure for FIPS
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
// stays unconditionally.
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set. Keep it so
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
// C->S MAC" when legacy mode replaces the default list.
algorithms.hmac = [
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256", "hmac-sha2-512",
"hmac-sha1-etm@openssh.com",
"hmac-sha1",
];
if (md5Supported()) {
algorithms.hmac.push("hmac-md5");
}
}
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
* (lower priority than modern ones) for compatibility with older network equipment.
*/
function buildAlgorithms(legacyEnabled) {
const algorithms = buildBaseAlgorithms();
if (legacyEnabled) {
applyLegacyAlgorithms(algorithms);
applyLegacyHmacAlgorithms(algorithms);
}
return algorithms;
}
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = buildBaseAlgorithms();
if (legacyEnabled) {
applyLegacyAlgorithms(algorithms);
applyLegacyHmacAlgorithms(algorithms);
}
return algorithms;
}
function _resetAlgorithmSupportCacheForTests() {
_md5Supported = null;
dhGroupSupport.clear();
}
module.exports = {
buildAlgorithms,
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
};

Some files were not shown because too many files have changed in this diff Show More