Compare commits

...

20 Commits

Author SHA1 Message Date
陈大猫
b6734b9ef9 Show auto-detected mosh path (#858)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-04-28 21:38:10 +08:00
陈大猫
fb443541aa Optimize snippets shortcut behavior
Fixes #839
2026-04-28 21:21:46 +08:00
yuzifu
7622c43c38 fix: consume SFTP side panel initial location once (#856) 2026-04-28 18:21:27 +08:00
陈大猫
a4a5c703b1 Fix terminal cursor preference handling 2026-04-28 17:17:37 +08:00
陈大猫
2063a5ccfe Expose data-role CSS hooks on chat messages (#854)
Closes #838.

Adds stable `data-role="user|assistant|system|tool"` attributes plus
`ai-chat-message` / `ai-chat-message-content` classnames on the chat
message rows in Catty Agent's chat panel. Users can now distinguish
their own messages from agent replies via Settings → Appearance →
Custom CSS, e.g.

  .ai-chat-message[data-role="user"] .ai-chat-message-content {
    background: rgba(91, 124, 250, 0.12);
  }

The default theme is intentionally minimal (bordered user bubble,
plain assistant text). Rather than change the default — different
users want different distinctions — this exposes a hook so anyone
can colour the rows however they prefer without forking.

The attribute names are part of the UI's stable contract; a comment
on the Message component flags this for future renames.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:34:30 +08:00
陈大猫
1fcf77ef4d Harden the dirty-editor quit guard (#853)
* Harden the dirty-editor quit guard

Follow-up to #840. Three concrete failure modes that round-2 review
turned up:

1. `webContents.send` is unguarded. If the renderer is destroyed
   between the reachability check and the send (e.g. a dying GPU
   process), the throw escapes the `before-quit` handler with
   `quitGuardChannelBusy = true` already set and no timeout scheduled
   yet — the app becomes un-quittable until restart. Wrap the send,
   and tear the listener/timer down on failure.

2. The timeout vs. response race silently commits a quit on
   `hasDirty=true`. Once `setTimeout` has already enqueued its
   callback for the next tick, `clearTimeout` is a no-op and the
   timeout callback runs even after the response arrived — which
   unconditionally calls `commitQuit()`, overriding the user's
   "save first" intent. Funnel both paths through a `settle()` helper
   that only acts the first time it's called.

3. The reply listener accepted any sender. A rogue or future-buggy
   `webContents` could decide the quit by sending the channel name
   first. Validate `evt.sender === wc` and ignore non-matches; switch
   from `.once` to `.on` + explicit `removeListener` so a rogue early
   reply doesn't consume the listener slot.

Also wrap the renderer-side handler in try/catch so an unexpected
throw inside `editorTabStore.getTabs()` reports `hasDirty=false`
immediately instead of stranding the main process for 5 s on a
silent timeout.

Verify `webContents.isCrashed()` before sending so a known-dead
renderer skips the round-trip and quits instantly instead of waiting
on the timeout fallback.

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

* Tighten dirty-editor quit-guard validation

Codex round-2-2 review suggested two small follow-ons:

1. Sender check should reject missing/falsy `evt.sender` outright. In
   real Electron IPC the sender is always populated; a falsy sender
   is anomalous and treating it as legit defeats the rogue-reply
   defence we just added.
2. Wrap `bridge.reportDirtyEditorsResult` in try/catch on the
   renderer side. If the IPC bridge is in a bad state and the call
   throws, the rest of the listener body is fine but the React
   useEffect callback would propagate the error — and an uncaught
   error in the listener would silently disable the quit guard for
   the rest of the session.

Both are pure tightening; no behaviour change on the happy path.

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-04-28 16:13:23 +08:00
秋秋
8296c2c780 fix(quit): target main window for dirty-editor check on quit (#840)
* fix(quit): target main window for dirty-editor check on quit

Use getMainWindow() instead of BrowserWindow.getAllWindows()[0] so the
app:query-dirty-editors round-trip isn't sent to the tray panel or
settings window, and skip the check when the main window is hidden to
avoid the 5s timeout fallback during tray-initiated quit.

* Also gate dirty-editor check on isMinimized for cross-platform robustness

A minimized main window has a taskbar/Dock entry the user can click to
restore, so the dirty-editor toast is still useful even though the
window isn't currently in the foreground. On some platforms isVisible()
can return false for a minimized window (see the comment at
globalShortcutBridge.cjs:478), so the original `!isVisible()`
short-circuit would silently lose dirty-editor protection in that case.

Treat a window as "reachable by the user" when either isVisible() or
isMinimized() is true. Truly hidden windows (close-to-tray, app.hide()
on macOS) still skip the round-trip and quit instantly, which is the
behaviour this PR set out to introduce.

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:03:44 +08:00
陈大猫
d1e6857f76 Drop stale lastIdlePrompt before forcing PowerShell wrapper (#852)
Follow-up to #851 (Codex review comment on 32bab2d4). After that PR,
`resolveEffectiveShellKind` flips an unknown-shell session to PowerShell
based on `session.lastIdlePrompt`, but that field is updated only when
`trackSessionIdlePrompt` recognizes a known prompt shape (default
PowerShell or `user@host[:path][#$]`). On an SSH/Telnet session that
enters PowerShell and then leaves it for a shell with an unrecognized
prompt — cmd.exe (`C:\>`), oh-my-posh / starship / a custom PS1 — the
cached `PS ...>` value persists indefinitely, and every subsequent MCP
command keeps getting wrapped as PowerShell against a non-PowerShell
shell. The new shell errors on the wrapper syntax once per command, and
nothing self-heals until the user reconnects.

Add `getFreshIdlePrompt(session)` which returns the cached prompt only
when the rolling PTY tail (`session._promptTrackTail`) still ends with
it. If the visible last line has moved on — even to a prompt shape we
don't recognize — the cache is treated as expired and downstream
wrapper selection / suffix matching falls back to `shellKind` alone,
which is the correct behavior for the unknown-shell case.

Wire this into the three call sites that previously read
`session.lastIdlePrompt || ""`:
- `aiBridge.cjs:1325` (Catty Agent foreground exec)
- `mcpServerBridge.cjs:1496` (MCP `terminal_execute`)
- `mcpServerBridge.cjs:1584` (MCP `terminal_start` background job)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:53:30 +08:00
陈大猫
eccb9f2cfc [codex] Fix PowerShell MCP command execution (#851)
* Fix PowerShell MCP command execution

* Harden PowerShell prompt detection and document its scope

- Annotate isPowerShellPrompt and the matching regex in shellUtils with
  a "default prompt only" caveat, so future readers know custom prompt
  themes (oh-my-posh, starship, custom prompt functions) are out of
  scope on purpose, and keep the two regexes in sync.
- Cover edge cases that the original tests left implicit: trailing
  whitespace after the `>`, ANSI-coloured prompts, bare `PS>` with no
  working directory, empty/undefined inputs, and command output that
  merely starts with `PS` (e.g. `PSO>`, `ZIPS>`) so we don't regress
  into mis-wrapping non-PowerShell sessions.

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

* Address multi-agent review findings on PowerShell prompt detection

- Refuse to override an explicit non-PowerShell shellKind. The override
  is only useful when the session has no confirmed shell type (the
  issue #841 case is an SSH session, where shellKind is undefined). On
  a confirmed bash/zsh/fish session a malicious remote process emitting
  a `PS ...>` line could otherwise coerce one mis-wrapped command; this
  closes that foothold while still fixing the original bug.
- Tighten the regex to /^PS(?:\s+\S.*)?>$/ so a literal `"PS >"` line
  is rejected. The default PowerShell prompt never emits that shape, so
  it's a clean spoof signal to ignore.
- Treat `\r` as a line break, not a stripped character, when extracting
  the last idle line. PSReadLine / ConPTY emit bare `\r` to repaint the
  current line; without this, `"PS C:\\old>\rPS C:\\new>"` would match
  as one long doubled prompt that never round-trips through the live
  PTY tail.
- Hoist the regex into shellUtils as `isDefaultPowerShellPromptLine` so
  prompt extraction and wrapper selection share one source of truth.
- Drop a redundant optional-chain on `String.prototype.split().pop()`.

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

* Drop dead 'powershell' entry from override set; document shellKind universe

Round-2 review noted that listing "powershell" in
SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE was a no-op: when the configured
shell kind is already powershell, the override path returns "powershell"
on a match and the fall-through returns "powershell" on a miss, so the
entry only mattered if reverse PS-to-POSIX detection were added later.
Removing it makes the gate's intent ("override only when there's no
confirmed shell type") obvious from the data alone.

Also enumerate the full universe of shellKind values in a comment next
to the set so the next reader doesn't have to grep terminalBridge and
localShell.cjs to know what's excluded and why ("raw" sessions bypass
buildWrappedCommand entirely; "cmd"/"fish" are confirmed and shouldn't
flip to PowerShell on a spoofed remote line).

Add a regression test that locks the current behavior for an explicit
shellKind="powershell" session whose visible prompt looks POSIX (e.g.
nested into WSL/bash) — we keep powershell wrapping. Lock this so a
future maintainer doesn't accidentally introduce reverse detection
without also handling the cross-shell quoting implications.

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-04-28 15:32:27 +08:00
陈大猫
74d56cdcb8 [codex] Settings: detect & override mosh client path (#849)
* Add Mosh client detection and override in Settings → Terminal

Builds on PR #847 (auto-detection across PATH gaps). Power users with
non-standard install locations (containers, custom builds, multiple
mosh versions) can now point the app at a specific mosh binary; less
technical users get a one-click "Detect" button to confirm where mosh
was found, with a Browse fallback for clicker-only flows.

Backend (electron/bridges/terminalBridge.cjs):
- detectMoshClient() returns { platform, found, path, searchedPaths }.
  Reuses resolvePosixExecutable; surfaces the searched dirs so the UI
  can tell users where to look when nothing was found.
- pickMoshClient() opens a native file picker via dialog.showOpenDialog.
- startMoshSession honors options.moshClientPath when provided. Strict
  failure: a missing/non-executable explicit path produces a clear
  error instead of falling back to auto-detect, so users notice typos
  and stale paths instead of getting silent recovery.

UI (components/settings/tabs/SettingsTerminalTab.tsx):
- New SettingRow under "Connection" with text input + Detect + Browse
  buttons, mirroring the localShell validation pattern. Shows inline
  validation (notFound/isDirectory) and the last detect result with
  searched directories on miss.

Plumbing:
- TerminalSettings.moshClientPath: string field with default "" so
  empty == auto-detect (matches existing PR #847 semantics).
- preload exposes detectMoshClient + pickMoshClient.
- createTerminalSessionStarters passes terminalSettings.moshClientPath
  into the IPC call, undefined when blank.
- en.ts / zh-CN.ts get the 9 new strings.

Verified locally:
- vite build succeeds; settings tab renders.
- detectMoshClient() against the live machine returns
  /opt/homebrew/bin/mosh with the expected searchedPaths list.
- Existing PR #847 auto-detection path is unchanged when the field is
  empty.

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

* Skip POSIX execute-bit check for explicit Windows mosh path

Address Codex P2 on PR #849 commit 88e5c596. isExecutableFile used
`(stat.mode & 0o111) !== 0` to gate the explicit moshClientPath in
startMoshSession, but Windows Node returns mode 0o100666 even for
.exe / .bat / .cmd files (NTFS has no POSIX execute bits). Result:
a Windows user who picked a perfectly valid `mosh.exe` via the new
Browse dialog or typed an absolute path was rejected with
"Configured Mosh client not usable…" — making the manual override
unusable on Windows.

Make isExecutableFile platform-aware: still require isFile() and
the Unix execute bit on POSIX, but treat any regular file as
executable on Win32 and let spawn-time PATHEXT / extension handling
filter non-executables.

Resolver paths are unaffected — resolvePosixExecutable returns null
on Win32 before isExecutableFile is reached.

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

* Augment Windows env when explicit mosh path is outside PATH

Address Codex P2 on PR #849 commit 69782471. When a Windows user
selected a mosh.exe outside %PATH% via Browse / custom path, the
explicit-client branch left resolvedMoshDir null, so the later
PATH/MOSH_CLIENT injection was skipped. The Mosh wrapper still
exec's `mosh-client` (and `ssh`) by name, so a valid selection
failed unless that directory was already on PATH.

- Always set resolvedMoshDir for explicit moshClientPath, regardless
  of platform.
- Use path.delimiter so PATH composition uses ";" on Win32 and ":"
  on POSIX. Compare directory membership with path.normalize so
  trailing-slash / case differences don't double-add.
- When picking mosh-client, try .exe / .bat / .cmd extensions on
  Win32 before the bare name; POSIX still uses just `mosh-client`.

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

* Validate Mosh client is executable in Settings UI

Address Codex P2 on PR #849 commit b6c384af. UI's debounced validator
called validatePath which only reported exists / isFile / isDirectory,
so a regular file without the POSIX execute bit (e.g. a stray
/etc/hosts-style path) was marked as valid in Settings — but
startMoshSession's isExecutableFile check then rejected the same path
at connect time, deferring the error until the user actually tried to
use Mosh.

- validatePath now returns `isExecutable: boolean`, mirroring
  isExecutableFile semantics (POSIX: stat.mode & 0o111; Win32: any
  regular file is treated as executable since NTFS lacks POSIX bits).
  Existing callers (localShell, localStartDir) ignore the new field.
- global.d.ts ValidatePath return type extended.
- SettingsTerminalTab Mosh validator surfaces a `notExecutable`
  message when the file exists but lacks exec permissions, keeping
  the UI in lockstep with main-process gating.
- en / zh-CN strings for the new state.

Verified: /bin/sh -> isExecutable:true, /etc/hosts -> false, /etc ->
false (directory). UI now warns immediately on the regression case.

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

* Require absolute Mosh client paths in Settings UI and main

Address Codex P2 on PR #849 commit 2eba549e. The shared validatePath
bridge resolves bare names through PATH (necessary for localShell
where 'powershell.exe' is a valid choice), so a user typing 'mosh' or
'mosh.exe' into the new Mosh field would get a green check in
Settings — but startMoshSession treats moshClientPath as a literal
filesystem path and calls isExecutableFile on the raw value. The
saved setting then disables auto-detection and Mosh sessions fail
unless a matching file happens to exist in the app's cwd.

Gate on absolute paths at both layers so UI validation and the
runtime check agree:

- startMoshSession: path.isAbsolute(expanded) before isExecutableFile,
  with a distinct error message naming the constraint.
- SettingsTerminalTab: same shape — UI checks looksAbsolute (POSIX
  /, leading ~, Windows drive letter, or UNC \\\\) before sending the
  IPC, surfacing notAbsolute inline. Tolerant across platforms so
  pasting a Windows-style path on macOS still produces a real
  downstream error rather than a misleading 'not absolute'.
- en / zh-CN strings.

Verified against the full case matrix (relative names, ./, ../, bare
basenames, POSIX absolute, ~/, Windows drive, UNC) — UI flags every
relative entry without an IPC round-trip, and any value that passes
UI also passes main-process validation (or both reject).

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-04-28 14:39:37 +08:00
陈大猫
cd04b0b33c [codex] Resolve mosh client across PATH gaps (closes #842) (#847)
* Resolve mosh client by absolute path on macOS / Linux

Closes #842.

macOS GUI Electron apps inherit launchd's reduced PATH
(/usr/bin:/bin:/usr/sbin:/sbin), missing /opt/homebrew/bin and other
common package-manager directories. The previous startMoshSession
called pty.spawn('mosh') with a bare name, so on Apple Silicon
Homebrew installs the spawn either failed silently or produced a
process that exited before the renderer could observe anything,
matching the issue: no terminal tab, no error toast, no DevTools log,
no network traffic.

- Add resolvePosixExecutable() that searches the inherited PATH and
  then a curated set of fallback directories (Homebrew arm64/x64,
  MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).
- Resolve `mosh` to an absolute path before spawning. When it cannot
  be located, throw an Error with an installation hint instead of
  letting pty.spawn fail in a way that stays invisible — the
  renderer's existing catch in createTerminalSessionStarters already
  surfaces the message via term.writeln + setError.
- Prepend the resolved binary's directory to env.PATH and set
  MOSH_CLIENT, so the mosh wrapper script (Perl) finds mosh-client
  and ssh next to it even when the launchd PATH is reduced.

Verified the resolver against a fake binary placed only in a fallback
dir while the simulated PATH was reduced to /usr/bin:/bin — the
function correctly returns the fallback hit. Win32 path through
findExecutable() is left unchanged.

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

* Resolve mosh against the merged child PATH

Address Codex P2 on PR #847 commit 314d396a: the resolver only checked
process.env.PATH plus hardcoded fallbacks, so a host that sets a custom
PATH via environmentVariables (later merged into the child env) could
trip the new "Mosh client not found" error even though the spawned
process would have had a valid PATH all along.

- Accept a { pathOverride } option on resolvePosixExecutable so the
  caller can pass the PATH the child will actually see.
- Pre-merge the host-supplied options.env.PATH (falling back to
  process.env.PATH when absent) and pass it to the resolver.
- Fallback dirs (Homebrew arm64/x64, MacPorts, ~/.nix-profile, etc.)
  still run after the override, so users who override PATH but forget
  to include their custom mosh location get the same silent rescue.

Verified four regression cases: no-override, Codex's custom-PATH
override, empty-string override, and opts-without-pathOverride —
each resolves the way the spawned process would.

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-04-28 09:42:19 +08:00
yuzifu
a29953f831 fix(session-logs): render terminal control sequences in saved logs (#832)
* fix(session-logs): render terminal control sequences in saved logs

Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase-line/display controls, and split CSI/OSC sequences correctly.

Stream txt/html auto-save through a persistent renderer and write rendered snapshots directly to the final log file, avoiding raw temp files and redundant full rewrites on session close. Keep raw log format unchanged.

* fix review issue

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-04-28 08:50:46 +08:00
陈大猫
c941038e68 [codex] Bundle Symbols Nerd Font Mono for terminal icon fallback (#846)
* Bundle Symbols Nerd Font Mono as terminal icon fallback

PR #845 added "Symbols Nerd Font Mono" to the terminal fontFamily
fallback chain so PUA glyphs (powerline / devicons / etc.) resolve
even when the user's primary font lacks them. That only worked if the
user had separately installed the symbol font; ship it ourselves so
icons render out of the box regardless of the chosen base font.

- Drop SymbolsNerdFontMono-Regular.ttf into public/fonts (~2.5 MB);
  Vite copies it to dist/fonts and the existing app:// protocol
  handler already knows the font/ttf MIME type.
- Register an @font-face in index.css pointing at the bundled file.
  font-display: block prevents tofu while the (instantly-available
  bundled) face loads, only affecting PUA glyphs since the base font
  is listed earlier in the fallback chain.
- Include the upstream LICENSE next to the font.

Source: ryanoasis/nerd-fonts NerdFontsSymbolsOnly v3.4.0 (MIT).

Refs #843

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

* Reference bundled font by absolute path so prod build resolves

Address Codex P2 on PR #846: the relative `./fonts/...` URL was emitted
verbatim into dist/assets/index-*.css, where the browser resolved it
against the CSS file's location and 404'd on
dist/assets/fonts/SymbolsNerdFontMono-Regular.ttf — the actual file
lives in dist/fonts/, so the icon fallback never loaded in packaged
builds and Nerd Font glyphs still rendered as tofu.

Switch the @font-face url() to `/fonts/...`. Vite's `base: "./"`
config rewrites that to the correct dist-relative form during build
(`../fonts/SymbolsNerdFontMono-Regular.ttf` from dist/assets/), and in
dev the same path is served by the Vite dev server out of public/.
Verified by re-running `vite build` and grepping the produced CSS.

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-04-28 08:39:01 +08:00
陈大猫
b1ab4d7105 [codex] Enable Nerd Font glyphs in terminal (#845)
* Enable Nerd Font glyphs in terminal font picker and rendering

- Grant local-fonts permission on the default session so queryLocalFonts()
  can enumerate user-installed fonts; without it the picker only showed
  the 20 hard-coded built-ins, hiding Nerd Font sub-families like
  "JetBrainsMono Nerd Font Mono".
- Append a Symbols Nerd Font fallback to the terminal fontFamily chain so
  PUA icons (powerline / devicons / etc.) resolve even when the primary
  font lacks them, matching the cross-font fallback behavior CoreText-based
  terminals like Ghostty already provide.
- Whitelist "Symbols Nerd Font" / "Symbols Nerd Font Mono" in the local
  monospace allow-list so the symbol-only icon font is not filtered out.

Refs #843

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

* Restrict permission handler to app origin

Address review feedback on PR #845: the previous permissive fallthrough
granted every permission request/check that hit the default session,
which the in-app OAuth flow uses too. That meant remote OAuth pages
(accounts.google.com, login.microsoftonline.com, ...) could be auto-
approved for camera, microphone, geolocation, notifications, etc.

Gate the handler on the requesting origin: only the app's own renderer
(app://netcatty plus the dev server in dev) gets the local-fonts grant
and the prior approve-by-default behavior. Anything loaded from a
third-party origin is denied outright.

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

* Use explicit permission allow-list for app origin

Address Codex P1 on PR #845 commit 975ca7e8: even after gating on the
app origin, the previous fallthrough still called callback(true) for
every non-local-fonts permission, so the main/settings renderers were
silently auto-granted notifications, geolocation, pointer lock, media,
etc. — none of which the app uses.

Replace the fallthrough with an explicit allow-list of the permissions
the renderer actually exercises (local-fonts plus clipboard read/write
for terminal + SFTP copy-paste). Anything outside that set is now
denied for the app origin too, matching the deny-by-default posture
Codex flagged.

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

* Match app:// origin by protocol+host, not URL.origin

Address Codex P1 on PR #845: in the packaged build the renderer loads
app://netcatty/index.html, but Node's WHATWG URL parser does not treat
app: as a standard scheme, so `new URL('app://netcatty/...').origin`
evaluates to the string "null". The previous Set-based origin check
therefore never matched the production renderer, causing the new
permission handlers to deny local-fonts as well as the existing
clipboard-read / clipboard-sanitized-write — breaking the font picker
and clipboard flows in release builds.

Compare protocol + host directly for app://, and keep the .origin
lookup for the dev server (which is HTTP-family and parses normally).
Verified against the relevant URL shapes (packaged main + settings,
dev server, third-party OAuth, file://).

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-04-28 08:30:20 +08:00
陈大猫
08e566adb0 [codex] Add X11 forwarding support (#835)
* Add X11 forwarding support

* Address X11 forwarding review feedback

* Handle X11 auth for unix socket display paths

* Tighten X11 forwarding compatibility handling
2026-04-28 07:54:26 +08:00
秋秋
df25d6c4b0 fix: resolve WebGL blank frame on resize and keep split pane bright on context menu (#837) 2026-04-26 05:45:22 +08:00
陈大猫
324301e61a Show SFTP toolbar button (#834) 2026-04-25 16:48:48 +08:00
陈大猫
2c3a8e7fb8 fix(cloud-sync): preserve adapter across browser handoff (closes #827) (#828)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The post-handoff `resetProviderStatus(provider)` call destroyed the
adapter that `startProviderAuth` had just created, because the hardened
`resetProviderStatus` now restores from the auth snapshot (which has
`adapter: null` for first-time connects). The subsequent OAuth callback
then failed with `google/onedrive adapter not initialized`, and the
error was persisted onto the provider state.

Introduce `clearConnectingStatus` for the "release connecting UI"
intent and switch the PKCE flow to use it, so adapter and auth
restore-snapshot are left untouched until the callback completes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:48:22 +08:00
陈大猫
bd2642be74 Replace outdated asset links in README
Updated asset links in the README for various features.
2026-04-24 00:20:36 +08:00
陈大猫
23151c9db8 Replace Netcatty image and update Catty Agent section
Updated the README to replace the Netcatty image with a new image and removed some content related to the Catty Agent.
2026-04-23 23:29:17 +08:00
54 changed files with 3247 additions and 260 deletions

46
App.tsx
View File

@@ -20,6 +20,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
@@ -880,9 +881,26 @@ function App({ settings }: { settings: SettingsState }) {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(() => {
const hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
bridge.reportDirtyEditorsResult?.(hasDirty);
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
try {
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
});
return unsub;
}, [t]);
@@ -1025,6 +1043,7 @@ function App({ settings }: { settings: SettingsState }) {
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
@@ -1286,9 +1305,23 @@ function App({ settings }: { settings: SettingsState }) {
setNavigateToSection('port');
break;
case 'snippets':
// Navigate to vault and open snippets section
setActiveTabId('vault');
setNavigateToSection('snippets');
{
const currentId = activeTabStore.getActiveTabId();
const intent = resolveSnippetsShortcutIntent({
activeTabId: currentId,
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
});
if (intent.kind === 'toggleTerminalScripts') {
toggleScriptsSidePanelRef.current();
break;
}
setActiveTabId('vault');
setNavigateToSection('snippets');
}
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
@@ -1926,6 +1959,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
/>

View File

@@ -40,7 +40,8 @@
---
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
---
@@ -48,11 +49,6 @@
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
<p align="center">
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
</p>
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
@@ -68,7 +64,10 @@
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
@@ -78,8 +77,9 @@ https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
@@ -160,21 +160,27 @@ Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
@@ -182,7 +188,11 @@ https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
@@ -190,7 +200,10 @@ https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
@@ -198,7 +211,11 @@ https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7

View File

@@ -375,6 +375,21 @@ const en: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.mosh.client': 'Mosh client path',
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
'settings.terminal.mosh.detect': 'Detect',
'settings.terminal.mosh.browse': 'Browse…',
'settings.terminal.mosh.autoDetected': 'Auto-detected',
'settings.terminal.mosh.detected': 'Detected at',
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
@@ -1077,6 +1092,9 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.x11Forwarding': 'Forward X11 apps',
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',

View File

@@ -712,6 +712,9 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.x11Forwarding': '转发 X11 图形应用',
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
'hostDetails.section.x11Forwarding': 'X11 转发',
'hostDetails.section.deviceType': '设备类型',
'hostDetails.deviceType': '网络设备模式',
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
@@ -1456,6 +1459,21 @@ const zhCN: Messages = {
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',
'settings.terminal.mosh.client': 'Mosh 客户端路径',
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local。',
'settings.terminal.mosh.client.placeholder': '自动探测',
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
'settings.terminal.mosh.detect': '探测',
'settings.terminal.mosh.browse': '浏览…',
'settings.terminal.mosh.autoDetected': '自动检测到',
'settings.terminal.mosh.detected': '已找到',
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveScriptsSidePanelShortcutIntent,
resolveSnippetsShortcutIntent,
} from "./resolveSnippetsShortcutIntent.ts";
test("active single terminal tab toggles the terminal scripts panel", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "s1",
sessionForTab: { id: "s1" },
workspaceForTab: null,
});
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
});
test("active workspace tab toggles the terminal scripts panel", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "w1",
sessionForTab: null,
workspaceForTab: { id: "w1" },
});
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
});
test("non-terminal tabs navigate to the vault snippets section", () => {
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
const result = resolveSnippetsShortcutIntent({
activeTabId,
sessionForTab: null,
workspaceForTab: null,
});
assert.deepEqual(result, { kind: "openVaultSnippets" });
}
});
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
const result = resolveSnippetsShortcutIntent({
activeTabId: "s1",
sessionForTab: { id: "s1" },
workspaceForTab: null,
terminalScriptsToggleAvailable: false,
});
assert.deepEqual(result, { kind: "openVaultSnippets" });
});
test("scripts panel shortcut closes when scripts is already open", () => {
const result = resolveScriptsSidePanelShortcutIntent("scripts");
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
});
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
for (const activePanel of [null, "sftp", "theme", "ai"]) {
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
assert.deepEqual(result, { kind: "openTerminalScripts" });
}
});

View File

@@ -0,0 +1,42 @@
export type SnippetsShortcutIntent =
| { kind: 'toggleTerminalScripts' }
| { kind: 'openVaultSnippets' };
export type ScriptsSidePanelShortcutIntent =
| { kind: 'closeTerminalSidePanel' }
| { kind: 'openTerminalScripts' };
export interface ResolveSnippetsShortcutIntentInput {
activeTabId: string | null;
sessionForTab: { id: string } | null;
workspaceForTab: { id: string } | null;
terminalScriptsToggleAvailable?: boolean;
}
export function resolveSnippetsShortcutIntent(
input: ResolveSnippetsShortcutIntentInput,
): SnippetsShortcutIntent {
const {
activeTabId,
sessionForTab,
workspaceForTab,
terminalScriptsToggleAvailable = true,
} = input;
if (!activeTabId) return { kind: 'openVaultSnippets' };
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
return { kind: 'toggleTerminalScripts' };
}
return { kind: 'openVaultSnippets' };
}
export function resolveScriptsSidePanelShortcutIntent(
activePanel: string | null,
): ScriptsSidePanelShortcutIntent {
if (activePanel === 'scripts') {
return { kind: 'closeTerminalSidePanel' };
}
return { kind: 'openTerminalScripts' };
}

View File

@@ -550,7 +550,10 @@ export const useCloudSync = (): CloudSyncHook => {
// Release the transient "connecting" UI once the browser handoff has
// happened. The callback session remains active in the background and
// will mark the provider connected when the redirect completes.
manager.resetProviderStatus(provider);
// Do NOT use resetProviderStatus here — it would restore from the
// auth snapshot and delete the adapter we just created, making the
// eventual completePKCEAuth call fail with "adapter not initialized".
manager.clearConnectingStatus(provider);
manager.clearProviderError(provider);
void completionPromise;
return data.url;

View File

@@ -408,6 +408,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
cleaned.fontSize = initialData?.fontSize;
}
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
delete cleaned.x11Forwarding;
}
onSave(cleaned);
};
@@ -1551,11 +1555,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
enabled={!!form.moshEnabled}
onToggle={() => {
const enabling = !form.moshEnabled;
if (enabling && form.deviceType === 'network') {
// Network device mode is incompatible with Mosh — clear it
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
if (enabling) {
setForm(prev => ({
...prev,
moshEnabled: true,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
} else {
update("moshEnabled", enabling);
update("moshEnabled", false);
}
}}
/>
@@ -1590,6 +1598,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
</div>
<ToggleRow
label={t("hostDetails.x11Forwarding")}
enabled={!!form.x11Forwarding}
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.x11Forwarding.desc")}
</p>
</Card>
)}
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">

View File

@@ -47,6 +47,7 @@ interface SftpSidePanelProps {
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
renderOverlays?: boolean;
@@ -67,6 +68,7 @@ interface SftpSidePanelProps {
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -77,6 +79,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode,
activeHost,
initialLocation,
onInitialLocationApplied,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
@@ -91,6 +94,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
}) => {
const { t } = useI18n();
@@ -465,16 +469,18 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
lastAppliedInitialLocationKeyRef.current = locationKey;
onInitialLocationApplied?.(initialLocation);
if (connection.currentPath === initialLocation.path) {
lastAppliedInitialLocationKeyRef.current = locationKey;
return;
}
lastAppliedInitialLocationKeyRef.current = locationKey;
sftpRef.current.navigateTo("left", initialLocation.path);
}, [
activeHost,
initialLocation,
onInitialLocationApplied,
sftp.leftPane,
]);
@@ -723,6 +729,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onRequestTerminalFocus={onRequestTerminalFocus}
t={t}
/>
)}
@@ -751,6 +758,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;

View File

@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
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 { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -184,6 +185,29 @@ function formatNetSpeed(bytesPerSec: number): string {
}
}
type XTermWithPrivateRenderService = XTerm & {
_core?: {
_renderService?: {
_renderRows?: (start: number, end: number) => void;
};
};
};
function forceSyncRenderAfterResize(term: XTerm): void {
const renderService = (term as XTermWithPrivateRenderService)._core?._renderService;
const renderRows = renderService?._renderRows;
if (typeof renderRows !== "function") return;
const endRow = term.rows - 1;
if (endRow < 0) return;
try {
renderRows.call(renderService, 0, endRow);
} catch (err) {
logger.warn("Sync render after resize failed", err);
}
}
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -982,8 +1006,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const runFit = () => {
try {
const term = termRef.current;
if (!term) return;
const dimensions = fitAddon.proposeDimensions();
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
// addon-fit 0.11 clears the renderer before resizing, which can show
// as a one-frame WebGL blink during layout changes. Resize directly
// using the proposed dimensions to preserve the existing behavior
// without forcing a blank intermediate frame.
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
term.resize(dimensions.cols, dimensions.rows);
forceSyncRenderAfterResize(term);
}
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
autocompleteRepositionRef.current?.();
@@ -1025,8 +1062,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.fontFamily = resolvedFontFamily;
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100

View File

@@ -43,6 +43,7 @@ import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { AIChatSidePanel } from './AIChatSidePanel';
import { useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
@@ -53,6 +54,7 @@ import { Input } from './ui/input';
import { RippleButton } from './ui/ripple';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -436,6 +438,7 @@ interface TerminalLayerProps {
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
}
@@ -492,6 +495,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
toggleScriptsSidePanelRef,
activeSidePanelTabRef,
}) => {
// Subscribe to activeTabId from external store
@@ -793,6 +797,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
});
}, []);
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
setSftpInitialLocationForTab(prev => {
const current = prev.get(tabId);
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
return prev;
}
const next = new Map(prev);
next.delete(tabId);
return next;
});
}, []);
// Focus-mode workspace sidebar resize handler. The sidebar is always
// anchored to the left of the workspace area, so a rightward drag grows it.
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
@@ -1294,9 +1310,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
[sidePanelOpenTabs],
);
const getActiveTerminalSessionId = useCallback((): string | null => {
if (!activeWorkspace) return activeSession?.id ?? null;
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
return focusedSessionId;
}
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
}, [activeWorkspace, activeSession?.id, sessions]);
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
const sessionId = getActiveTerminalSessionId();
if (!sessionId) return null;
try {
const result = await terminalBackend.getSessionPwd(sessionId);
@@ -1304,27 +1337,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
} catch {
return null;
}
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
}, [getActiveTerminalSessionId, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
if (!sessionId) return;
const focusTarget = () => {
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
};
requestAnimationFrame(() => {
focusTarget();
setTimeout(focusTarget, 50);
});
focusTerminalSessionInput(sessionId);
}, []);
const refocusActiveTerminalSession = useCallback(() => {
const sessionId = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionId);
refocusTerminalSession(sessionId);
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
if (!activeTabId) return;
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
@@ -1348,7 +1377,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return next;
});
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
useEffect(() => {
if (!closeSidePanelRef) return;
@@ -1403,6 +1432,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSwitchSidePanelTab('scripts');
}, [handleSwitchSidePanelTab]);
const handleToggleScriptsSidePanel = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const intent = resolveScriptsSidePanelShortcutIntent(
sidePanelOpenTabsRef.current.get(tabId) ?? null,
);
if (intent.kind === 'closeTerminalSidePanel') {
handleCloseSidePanel();
return;
}
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'scripts');
return next;
});
}, [handleCloseSidePanel]);
useEffect(() => {
if (!toggleScriptsSidePanelRef) return;
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
return () => {
toggleScriptsSidePanelRef.current = null;
};
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
handleSwitchSidePanelTab('theme');
@@ -2271,6 +2328,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
? (sftpInitialLocationForTab.get(tabId) ?? null)
: null
}
onInitialLocationApplied={(location) => handleSftpInitialLocationApplied(tabId, location)}
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
isVisible={isVisibleSftpPanel}
renderOverlays={isVisibleSftpPanel}
@@ -2285,6 +2343,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}
/>
);
})}
@@ -2599,6 +2658,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities
);
};

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { vaultViewAreEqual } from "./VaultView.tsx";
test("VaultView re-renders when an external section navigation request changes", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, navigateToSection: "snippets" } as never,
),
false,
);
});

View File

@@ -3199,7 +3199,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
// Only re-render when data props change - isActive is now managed internally via store subscription
const vaultViewAreEqual = (
export const vaultViewAreEqual = (
prev: VaultViewProps,
next: VaultViewProps,
): boolean => {
@@ -3217,7 +3217,8 @@ const vaultViewAreEqual = (
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize;
prev.terminalFontSize === next.terminalFontSize &&
prev.navigateToSection === next.navigateToSection;
return isEqual;
};

View File

@@ -9,27 +9,37 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
};
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
// .ai-chat-message[data-role="user"] — outer row, user-authored
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
// .ai-chat-message-content[data-role=...] — inner bubble / content area
// These attributes are part of the UI's stable contract; do not rename
// without updating Custom CSS docs.
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-1.5',
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
className,
)}
data-role={from}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
from?: 'user' | 'assistant' | 'system' | 'tool';
};
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}
data-role={from}
{...props}
>
{children}

View File

@@ -196,7 +196,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
return (
<Message key={message.id} from={message.role}>
<MessageContent>
<MessageContent from={message.role}>
{/* Thinking block */}
{!isUser && message.thinking && (
<ThinkingBlock

View File

@@ -300,6 +300,16 @@ export default function SettingsTerminalTab(props: {
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
// Mosh settings state
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [moshDetectStatus, setMoshDetectStatus] = useState<
| { kind: "idle" }
| { kind: "running" }
| { kind: "found"; path: string }
| { kind: "not-found"; searchedPaths: string[] }
>({ kind: "idle" });
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
const discoveredShells = useDiscoveredShells();
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
if (!terminalSettings.localShell) return false;
@@ -455,6 +465,109 @@ export default function SettingsTerminalTab(props: {
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, discoveredShells, t]);
// Validate mosh client path when it changes (debounced)
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const moshPath = terminalSettings.moshClientPath;
if (!moshPath) {
setMoshValidation(null);
return;
}
// The shared validatePath bridge resolves bare names through PATH (good
// for localShell where "powershell.exe" is a valid choice), but
// startMoshSession treats moshClientPath as a literal filesystem path —
// so any non-absolute entry would look valid here yet fail at connect
// time. Gate on absolute paths first; accept ~ since the main process
// will expand it. Tolerant across platforms so e.g. a user pasting a
// Windows-style absolute path on macOS still gets a real error
// downstream rather than a misleading "not absolute".
const looksAbsolute =
moshPath.startsWith("/") ||
moshPath.startsWith("~") ||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
moshPath.startsWith("\\\\");
if (!looksAbsolute) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
return;
}
if (!bridge?.validatePath) {
setMoshValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(moshPath, "file").then((result) => {
if (result.exists && result.isFile && !result.isExecutable) {
// Stays consistent with startMoshSession's isExecutableFile check —
// a regular file without the execute bit can't actually launch.
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
} else if (result.exists && result.isFile) {
setMoshValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
} else {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
}
}).catch(() => {
setMoshValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.moshClientPath, t]);
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
let canceled = false;
bridge.detectMoshClient()
.then((result) => {
if (!canceled) {
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
}
})
.catch(() => {
if (!canceled) setAutoDetectedMoshPath(null);
});
return () => {
canceled = true;
};
}, []);
const handleDetectMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
setMoshDetectStatus({ kind: "running" });
try {
const result = await bridge.detectMoshClient();
if (result.found && result.path) {
setMoshDetectStatus({ kind: "found", path: result.path });
// Auto-fill the input only when it is empty so we don't override
// a value the user is in the middle of editing.
if (!terminalSettings.moshClientPath) {
updateTerminalSetting("moshClientPath", result.path);
}
} else {
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
}
} catch (err) {
console.error("[Settings] detectMoshClient failed:", err);
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
}
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
const handleBrowseMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.pickMoshClient) return;
try {
const result = await bridge.pickMoshClient();
if (!result.canceled && result.filePath) {
updateTerminalSetting("moshClientPath", result.filePath);
setMoshDetectStatus({ kind: "idle" });
}
} catch (err) {
console.error("[Settings] pickMoshClient failed:", err);
}
}, [updateTerminalSetting]);
// Validate directory path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
@@ -1034,6 +1147,85 @@ export default function SettingsTerminalTab(props: {
className="w-24"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.x11Display")}
description={t("settings.terminal.connection.x11Display.desc")}
>
<Input
value={terminalSettings.x11Display}
onChange={(e) => updateTerminalSetting("x11Display", e.target.value)}
placeholder={t("settings.terminal.connection.x11Display.placeholder")}
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.mosh.client")}
description={t("settings.terminal.mosh.client.desc")}
>
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
<Input
value={terminalSettings.moshClientPath}
placeholder={t("settings.terminal.mosh.client.placeholder")}
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
className={cn(
"flex-1",
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDetectMosh}
disabled={moshDetectStatus.kind === "running"}
>
{t("settings.terminal.mosh.detect")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleBrowseMosh}
>
{t("settings.terminal.mosh.browse")}
</Button>
</div>
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
</span>
)}
{moshValidation && !moshValidation.valid && moshValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{moshValidation.message}
</span>
)}
{moshDetectStatus.kind === "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
</span>
)}
{moshDetectStatus.kind === "not-found" && (
<span className="text-xs text-destructive flex items-start gap-1">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>
{t("settings.terminal.mosh.notDetected")}
{moshDetectStatus.searchedPaths.length > 0 && (
<>
{" "}
<span className="text-muted-foreground">
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
</span>
</>
)}
</span>
</span>
)}
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />

View File

@@ -46,6 +46,7 @@ interface SftpOverlaysProps {
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
onRequestTerminalFocus?: () => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
@@ -83,6 +84,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
handleFileOpenerSelect,
handleSelectSystemApp,
onPromoteToTab,
onRequestTerminalFocus,
}) => {
return (
<>
@@ -141,6 +143,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
onRequestTerminalFocus?.();
}}
fileName={textEditorTarget?.file.name || ""}
initialContent={textEditorContent}

View File

@@ -10,7 +10,7 @@ import {
Terminal as TerminalIcon,
Trash2,
} from 'lucide-react';
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
@@ -59,6 +59,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
}) => {
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
// Tracks the .workspace-pane whose context menu is currently open so we can
// keep its `:focus-within`-driven opacity stable while focus is in the
// menu portal (otherwise the pane dims for the menu's lifetime).
const markedPaneRef = useRef<HTMLElement | null>(null);
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
markedPaneRef.current?.removeAttribute('data-menu-open');
markedPaneRef.current = null;
}
}, []);
// Helper to get shortcut from keyBindings and format for display
const getShortcut = (bindingId: string): string => {
@@ -91,7 +102,15 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
if (e.shiftKey || rightClickBehavior === 'context-menu') {
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
if (pane) {
markedPaneRef.current?.removeAttribute('data-menu-open');
pane.setAttribute('data-menu-open', '');
markedPaneRef.current = pane;
}
return;
}
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
@@ -107,7 +126,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// Always use ContextMenu wrapper to maintain consistent React tree structure
// This prevents terminal from unmounting when rightClickBehavior changes
return (
<ContextMenu>
<ContextMenu onOpenChange={handleOpenChange}>
<ContextMenuTrigger
asChild
onContextMenu={handleRightClick}

View File

@@ -0,0 +1,54 @@
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 type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
const sshHost: Host = {
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
};
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalToolbar, {
status,
host,
onOpenSFTP: () => {},
onOpenScripts: () => {},
onOpenTheme: () => {},
}),
),
);
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
const markup = renderToolbar(sshHost);
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
const moreIndex = markup.indexOf('aria-label="More actions"');
assert.notEqual(sftpIndex, -1);
assert.notEqual(moreIndex, -1);
assert.ok(sftpIndex < moreIndex);
});
test("hides SFTP for local terminal sessions", () => {
const markup = renderToolbar({
...sshHost,
id: "local-1",
protocol: "local",
});
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
});

View File

@@ -1,6 +1,6 @@
/**
* Terminal Toolbar
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
* Displays high-frequency terminal actions and close button in the terminal status bar.
*/
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
@@ -82,6 +82,26 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
buttonClassName={buttonBase}
/>
{!hidesSftp && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
onClick={onOpenSFTP}
disabled={status !== 'connected'}
>
<FolderInput size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -114,9 +134,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
</Tooltip>
{/* Overflow menu — collapses the four opener-style actions
(SFTP / Encoding / Scripts / Terminal Settings) behind a
single ⋮ trigger so the toolbar doesn't feel crowded.
{/* Overflow menu — keeps lower-frequency opener-style actions
(Encoding / Scripts / Terminal Settings) behind a single
trigger so the toolbar doesn't feel crowded.
Highlight / Compose / Search stay visible because they
are toggled mid-session, not just once. */}
<Popover
@@ -154,21 +174,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
}
}}
>
{!hidesSftp && (
<PopoverClose asChild>
<button
type="button"
className={cn(menuItemClass, status !== 'connected' && "opacity-50 pointer-events-none")}
onClick={onOpenSFTP}
disabled={status !== 'connected'}
>
<FolderInput size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
</span>
</button>
</PopoverClose>
)}
<PopoverClose asChild>
<button type="button" className={menuItemClass} onClick={onOpenScripts}>
<Zap size={12} className="shrink-0" />

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { focusTerminalSessionInput } from "./focusTerminalSession";
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
const focusCalls: string[] = [];
const textarea = {
focus: () => focusCalls.push("focus"),
};
const pane = {
querySelector: (selector: string) => {
assert.equal(selector, "textarea.xterm-helper-textarea");
return textarea;
},
};
const queriedSelectors: string[] = [];
const doc = {
querySelector: (selector: string) => {
queriedSelectors.push(selector);
return pane;
},
};
const timeouts: number[] = [];
focusTerminalSessionInput("session-1", {
document: doc,
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback, delay) => {
timeouts.push(delay);
callback();
return delay;
},
});
assert.deepEqual(queriedSelectors, [
'[data-session-id="session-1"]',
'[data-session-id="session-1"]',
]);
assert.deepEqual(timeouts, [50]);
assert.deepEqual(focusCalls, ["focus", "focus"]);
});
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
assert.doesNotThrow(() => {
focusTerminalSessionInput(null, {
document: undefined,
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback, delay) => {
callback();
return delay;
},
});
});
});

View File

@@ -0,0 +1,57 @@
type QueryTarget = {
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
};
type FocusableTarget = {
focus?: () => void;
};
interface FocusTerminalSessionInputOptions {
document?: QueryTarget | null;
requestAnimationFrame?: (callback: () => void) => unknown;
setTimeout?: (callback: () => void, delay: number) => unknown;
retryDelays?: readonly number[];
}
const escapeAttributeValue = (value: string): string =>
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
export const focusTerminalSessionInput = (
sessionId: string | null | undefined,
options: FocusTerminalSessionInputOptions = {},
): void => {
if (!sessionId) return;
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
if (!doc) return;
const raf = options.requestAnimationFrame
?? (typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame
: (callback: () => void) => {
callback();
return undefined;
});
const scheduleTimeout = options.setTimeout
?? (typeof setTimeout !== "undefined"
? setTimeout
: (callback: () => void) => {
callback();
return undefined;
});
const retryDelays = options.retryDelays ?? [50];
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
const focusTarget = () => {
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
textarea?.focus?.();
};
raf(() => {
focusTarget();
retryDelays.forEach((delay) => {
scheduleTimeout(focusTarget, delay);
});
});
};

View File

@@ -569,6 +569,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
: undefined,
agentForwarding: ctx.host.agentForwarding,
x11Forwarding: ctx.host.x11Forwarding,
x11Display: ctx.terminalSettings?.x11Display,
legacyAlgorithms: ctx.host.legacyAlgorithms,
cols: term.cols,
rows: term.rows,
@@ -759,6 +761,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: ctx.host.username || "root",
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,

View File

@@ -37,6 +37,7 @@ import {
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import type {
Host,
KeyBinding,
@@ -830,6 +831,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
});
const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
@@ -857,6 +860,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
eraseScrollbackDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
cursorPreferenceDisposable?.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -0,0 +1,160 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyUserCursorBlinkPreference,
applyUserCursorPreference,
installUserCursorPreferenceGuard,
resolveUserCursorPreference,
} from "./cursorPreference";
test("resolveUserCursorPreference defaults to a blinking block cursor", () => {
assert.deepEqual(resolveUserCursorPreference(undefined), {
cursorShape: "block",
cursorBlink: true,
});
});
test("applyUserCursorPreference clears terminal-side cursor overrides before applying user settings", () => {
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "bar" as const,
cursorBlink: false,
},
},
},
};
applyUserCursorPreference(term, {
cursorShape: "underline",
cursorBlink: true,
});
assert.equal(term.options.cursorStyle, "underline");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, undefined);
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("applyUserCursorBlinkPreference keeps remote cursor shape overrides intact", () => {
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "bar" as const,
cursorBlink: false,
},
},
},
};
applyUserCursorBlinkPreference(term, {
cursorShape: "underline",
cursorBlink: true,
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "bar");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("installUserCursorPreferenceGuard restores blink without consuming cursor-style overrides", async () => {
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
const parser = {
registerCsiHandler(this: typeof parser, id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) {
assert.equal(this, parser);
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
return { dispose: () => undefined };
},
};
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
parser,
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "block" as const,
cursorBlink: false,
},
},
},
};
const settingsRef = {
current: {
cursorShape: "bar",
cursorBlink: true,
},
};
installUserCursorPreferenceGuard(term, settingsRef);
const handled = handlers.get("| |q")?.([2]);
assert.equal(handled, false);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("installUserCursorPreferenceGuard restores cursor blink after private mode changes", async () => {
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
parser: {
registerCsiHandler: (id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) => {
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
return { dispose: () => undefined };
},
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "block" as const,
cursorBlink: false,
},
},
},
};
const settingsRef = {
current: {
cursorShape: "underline",
cursorBlink: true,
},
};
installUserCursorPreferenceGuard(term, settingsRef);
const handled = handlers.get("?||l")?.([12]);
assert.equal(handled, false);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});

View File

@@ -0,0 +1,118 @@
import type { IDisposable, Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import type { TerminalSettings } from "../../../types";
type CursorPreferenceSettings = Pick<TerminalSettings, "cursorShape" | "cursorBlink">;
type MutableCursorOptions = {
cursorStyle?: "block" | "bar" | "underline";
cursorBlink?: boolean;
};
type TerminalLike = {
options: MutableCursorOptions;
parser?: {
registerCsiHandler?: (
id: { prefix?: string; intermediates?: string; final: string },
callback: (params: readonly (number | number[])[]) => boolean,
) => IDisposable;
};
_core?: {
coreService?: {
decPrivateModes?: {
cursorStyle?: "block" | "bar" | "underline";
cursorBlink?: boolean;
};
};
};
};
const scheduleAfterDefaultHandler = (callback: () => void): void => {
if (typeof queueMicrotask === "function") {
queueMicrotask(callback);
return;
}
setTimeout(callback, 0);
};
const hasCursorBlinkPrivateModeParam = (params: readonly (number | number[])[]): boolean => (
params.some((param) => (
Array.isArray(param)
? param.includes(12)
: param === 12
))
);
export const resolveUserCursorPreference = (
settings: Partial<CursorPreferenceSettings> | undefined,
): Required<CursorPreferenceSettings> => ({
cursorShape: settings?.cursorShape ?? "block",
cursorBlink: settings?.cursorBlink ?? true,
});
export const applyUserCursorPreference = (
term: TerminalLike,
settings: Partial<CursorPreferenceSettings> | undefined,
): void => {
const preference = resolveUserCursorPreference(settings);
const privateModes = term._core?.coreService?.decPrivateModes;
if (privateModes) {
privateModes.cursorStyle = undefined;
privateModes.cursorBlink = undefined;
}
term.options.cursorStyle = preference.cursorShape;
term.options.cursorBlink = preference.cursorBlink;
};
export const applyUserCursorBlinkPreference = (
term: TerminalLike,
settings: Partial<CursorPreferenceSettings> | undefined,
): void => {
const preference = resolveUserCursorPreference(settings);
const privateModes = term._core?.coreService?.decPrivateModes;
if (privateModes) {
privateModes.cursorBlink = undefined;
}
term.options.cursorBlink = preference.cursorBlink;
};
export const installUserCursorPreferenceGuard = (
term: XTerm | TerminalLike,
terminalSettingsRef: RefObject<TerminalSettings | undefined>,
): IDisposable | null => {
const terminal = term as TerminalLike;
const parser = terminal.parser;
if (!parser?.registerCsiHandler) return null;
const registerCsiHandler = parser.registerCsiHandler.bind(parser);
const applyBlinkPreference = () => applyUserCursorBlinkPreference(terminal, terminalSettingsRef.current);
const cursorStyleDisposable = registerCsiHandler({ intermediates: " ", final: "q" }, () => {
scheduleAfterDefaultHandler(applyBlinkPreference);
return false;
});
const cursorBlinkSetDisposable = registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
if (hasCursorBlinkPrivateModeParam(params)) {
scheduleAfterDefaultHandler(applyBlinkPreference);
}
return false;
});
const cursorBlinkResetDisposable = registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
if (hasCursorBlinkPrivateModeParam(params)) {
scheduleAfterDefaultHandler(applyBlinkPreference);
}
return false;
});
return {
dispose: () => {
cursorStyleDisposable.dispose();
cursorBlinkSetDisposable.dispose();
cursorBlinkResetDisposable.dispose();
},
};
};

View File

@@ -78,6 +78,7 @@ export interface Host {
savePassword?: boolean; // Whether to save the password (default: true)
authMethod?: 'password' | 'key' | 'certificate';
agentForwarding?: boolean;
x11Forwarding?: boolean;
createdAt?: number; // Timestamp when host was created
startupCommand?: string;
hostChaining?: string; // Deprecated: use hostChain instead
@@ -490,6 +491,13 @@ export interface TerminalSettings {
// SSH Connection
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
// Mosh Connection
// Absolute path to the local `mosh` client binary. Empty triggers
// auto-discovery (PATH + Homebrew/MacPorts/nix fallbacks). When set,
// the value is used as-is and a missing file produces a clear error.
moshClientPath: string;
// Server Stats Display (Linux only)
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
@@ -635,6 +643,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Empty = auto-detect mosh on PATH / common install dirs
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { serializeHostsToSshConfig } from "./sshConfigSerializer.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "X11 Host",
hostname: "x11.example.com",
username: "root",
port: 22,
protocol: "ssh",
os: "linux",
tags: [],
...overrides,
});
test("serializeHostsToSshConfig writes ForwardX11 for hosts with X11 forwarding enabled", () => {
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: true })]);
assert.match(config, /ForwardX11 yes/);
});
test("serializeHostsToSshConfig omits ForwardX11 when X11 forwarding is disabled", () => {
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: false })]);
assert.doesNotMatch(config, /ForwardX11/);
});
test("serializeHostsToSshConfig omits ForwardX11 for mosh hosts", () => {
const config = serializeHostsToSshConfig([makeHost({ moshEnabled: true, x11Forwarding: true })]);
assert.doesNotMatch(config, /ForwardX11/);
});

View File

@@ -113,6 +113,10 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
lines.push(` Port ${host.port}`);
}
if (host.x11Forwarding && !host.moshEnabled) {
lines.push(" ForwardX11 yes");
}
// Serialize IdentityFile paths
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
for (const keyPath of host.identityFilePaths) {

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { importVaultHostsFromText } from "./vaultImport.ts";
test("ssh_config import maps ForwardX11 yes to host X11 forwarding", () => {
const result = importVaultHostsFromText("ssh_config", [
"Host x11-host",
" HostName x11.example.com",
" User root",
" ForwardX11 yes",
].join("\n"));
assert.equal(result.hosts.length, 1);
assert.equal(result.hosts[0].x11Forwarding, true);
});
test("ssh_config import maps ForwardX11 no to disabled host X11 forwarding", () => {
const result = importVaultHostsFromText("ssh_config", [
"Host no-x11-host",
" HostName no-x11.example.com",
" User root",
" ForwardX11 no",
].join("\n"));
assert.equal(result.hosts.length, 1);
assert.equal(result.hosts[0].x11Forwarding, false);
});

View File

@@ -526,6 +526,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
port?: number;
proxyJump?: string;
identityFiles?: string[];
forwardX11?: boolean;
};
const blocks: Block[] = [];
@@ -564,6 +565,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
else if (keyword === "forwardx11") current.forwardX11 = value.toLowerCase() === "yes";
else if (keyword === "identityfile") {
if (!current.identityFiles) current.identityFiles = [];
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
@@ -614,6 +616,9 @@ const importFromSshConfig = (text: string): VaultImportResult => {
if (block.identityFiles && block.identityFiles.length > 0) {
host.identityFilePaths = [...block.identityFiles];
}
if (block.forwardX11 !== undefined) {
host.x11Forwarding = block.forwardX11;
}
parsedHosts.push(host);
@@ -1092,4 +1097,3 @@ export const exportHostsToCsvWithStats = (hosts: Host[]): ExportHostsResult => {
skippedCount: skippedHosts.length,
};
};

View File

@@ -12,7 +12,7 @@
const crypto = require("crypto");
const { StringDecoder } = require("node:string_decoder");
const iconv = require("iconv-lite");
const { stripAnsi } = require("./shellUtils.cjs");
const { stripAnsi, isDefaultPowerShellPromptLine } = require("./shellUtils.cjs");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
// Build a stateful decoder for a full exec call. Serial data events can
@@ -86,6 +86,54 @@ function escapeCmdForNestedShell(text) {
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
}
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
// that emit ``/`λ`/etc.) intentionally fall through — we'd rather miss
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
// in shellUtils.cjs so prompt extraction and wrapper selection share one
// source of truth.
function isPowerShellPrompt(prompt) {
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
// not the doubled string.
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultPowerShellPromptLine(lastLine);
}
// Prompt-driven override is intentionally narrow: only flip to PowerShell
// when the session has no confirmed shell type. This keeps the issue #841
// fix working (SSH/Telnet sessions never set shellKind — see
// sshBridge.cjs:1265) while preventing a malicious remote process from
// spoofing a `PS ...>` line on a real bash/zsh/fish/cmd session to coerce
// a single mis-wrapped command.
//
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
// terminalBridge.cjs:368, :932, :1074):
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
// Excluded on purpose:
// - "posix" / "fish" / "cmd": confirmed POSIX-family or cmd.exe — never override.
// - "powershell": already correct; no override needed (would be a no-op).
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
"",
"unknown",
]);
function resolveEffectiveShellKind(shellKind, expectedPrompt) {
const baseKind = shellKind || "";
if (
SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind) &&
isPowerShellPrompt(expectedPrompt)
) {
return "powershell";
}
return baseKind || "posix";
}
function buildWrappedCommand(command, shellKind, marker) {
switch (shellKind) {
case "powershell": {
@@ -305,7 +353,7 @@ function startPtyJob(ptyStream, command, options) {
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt);
const CANCEL_RETRY_MS = 5000;
const CANCEL_WALL_TIMEOUT_MS = 30000;
@@ -1133,5 +1181,6 @@ module.exports = {
execViaChannel,
execViaRawPty,
detectShellKind,
resolveEffectiveShellKind,
stripAnsi,
};

View File

@@ -0,0 +1,109 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
resolveEffectiveShellKind,
} = require("./ptyExec.cjs");
test("uses PowerShell wrapping when a session with no confirmed shell sees a PowerShell prompt", () => {
// SSH sessions don't set shellKind (sshBridge never assigns one), which
// is exactly the issue #841 case the override targets.
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
"powershell",
);
});
test("uses PowerShell wrapping when shellKind is 'unknown'", () => {
assert.equal(
resolveEffectiveShellKind("unknown", "PS C:\\Users\\alice>"),
"powershell",
);
});
test("does NOT override an explicit non-PowerShell shell kind even if the prompt looks like PowerShell", () => {
// Defends against a malicious remote process spoofing a `PS ...>` line
// on a real bash/zsh/cmd/fish/raw session to coerce a single
// mis-wrapped command.
assert.equal(
resolveEffectiveShellKind("posix", "PS C:\\Users\\alice>"),
"posix",
);
assert.equal(
resolveEffectiveShellKind("fish", "PS C:\\Users\\alice>"),
"fish",
);
assert.equal(
resolveEffectiveShellKind("cmd", "PS C:\\Users\\alice>"),
"cmd",
);
assert.equal(
resolveEffectiveShellKind("raw", "PS C:\\Users\\alice>"),
"raw",
);
});
test("keeps powershell wrapping for an explicit powershell session even when nested into a non-PS shell", () => {
// After `wsl` or similar, a confirmed PowerShell session may show a
// posix prompt. We currently keep PowerShell wrapping (the user's
// configured shell is the source of truth). Reverse detection would
// be a separate feature; this test locks the current behavior so a
// future change is intentional.
assert.equal(
resolveEffectiveShellKind("powershell", "alice@host:~$"),
"powershell",
);
assert.equal(
resolveEffectiveShellKind("powershell", ""),
"powershell",
);
});
test("recognizes a PowerShell prompt that has trailing whitespace", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice> "),
"powershell",
);
});
test("recognizes a bare PowerShell prompt without a working directory", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PS>"), "powershell");
});
test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS /home/alice>"),
"powershell",
);
});
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
"powershell",
);
});
test("treats a CR-redrawn last line as the effective prompt, not the doubled string", () => {
// PSReadLine / ConPTY emit `\r` to repaint the current line. Without
// CR-as-newline normalization the regex would match a doubled prompt
// string that never round-trips through the live PTY tail.
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\old>\rPS C:\\new>"),
"powershell",
);
});
test("rejects spoofed `PS >` (literal space then `>`) — default PowerShell never emits this", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PS >"), "posix");
});
test("falls back to posix when neither shell kind nor prompt is informative", () => {
assert.equal(resolveEffectiveShellKind(undefined, ""), "posix");
assert.equal(resolveEffectiveShellKind(null, undefined), "posix");
});
test("does not misclassify command output that happens to contain 'PS'", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PSO>"), "posix");
assert.equal(resolveEffectiveShellKind(undefined, "ZIPS>"), "posix");
});

View File

@@ -24,14 +24,33 @@ function stripAnsi(input) {
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `PS /home/alice>`). Anchored so command output that merely starts with
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `"PS >"` (which the default prompt never emits) so a script that prints
// such a line can't trick prompt-driven shell-kind selection.
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
}
function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "");
// Treat `\r` as a line break, not as a stripped character: PSReadLine /
// ConPTY repaints emit bare `\r` to redraw the current line, and we
// want only the redrawn line to be considered, not the concatenation
// of every overwritten frame.
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (isDefaultPowerShellPromptLine(rightTrimmed)) {
return lastLine;
}
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
return lastLine;
}
@@ -54,6 +73,32 @@ function trackSessionIdlePrompt(session, chunk) {
return prompt;
}
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell or
// `user@host[:path][#$]`); a remote shell switch into cmd.exe, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
// wrapper. By re-checking the live tail we self-correct: if the visible
// last line no longer matches the cached prompt, the prompt is treated
// as expired and downstream wrapper selection / suffix matching falls
// back to `shellKind` alone.
function getFreshIdlePrompt(session) {
if (!session) return "";
const cached = session.lastIdlePrompt;
if (!cached) return "";
const tail = session._promptTrackTail;
if (typeof tail !== "string" || !tail) return "";
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
if (!normalizedCached) return "";
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -319,6 +364,8 @@ function serializeStreamChunk(chunk) {
module.exports = {
stripAnsi,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
trackSessionIdlePrompt,
isLocalhostHostname,
extractFirstNonLocalhostUrl,

View File

@@ -0,0 +1,140 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
test("extracts a trailing PowerShell idle prompt", () => {
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice>"),
"PS C:\\Users\\alice>",
);
});
test("preserves trailing whitespace on a captured PowerShell prompt", () => {
// The wrapper-selection logic trims this, but the suffix-match logic in
// hasExpectedPromptSuffix() compares against raw PTY bytes, so the trailing
// space PowerShell emits after `>` must round-trip unchanged.
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice> "),
"PS C:\\Users\\alice> ",
);
});
test("extracts a bare PowerShell prompt with no working directory", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS>"), "PS>");
});
test("does not extract content that merely looks PowerShell-ish", () => {
// Any non-prompt output ending in `PSO>` or `ZIPS>` would have produced a
// trailing newline before the next prompt; this guards against the regex
// accidentally matching command output that just happens to contain "PS".
assert.equal(extractTrailingIdlePrompt("nope\r\nPSO>"), "");
assert.equal(extractTrailingIdlePrompt("nope\r\nZIPS>"), "");
});
test("rejects `PS >` (literal `PS` + space + `>`) so spoofed scripts can't masquerade as a default prompt", () => {
// Default PowerShell never emits this shape; rejecting it makes the
// override harder to coerce via printed output.
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS >"), "");
});
test("treats CR repaints as line breaks so only the redrawn line is captured", () => {
// PSReadLine / ConPTY emit bare `\r` to repaint the current line. The
// captured prompt must equal the visible last line, not the
// concatenation of every overwritten frame, so hasExpectedPromptSuffix
// can still match the live PTY tail later.
assert.equal(
extractTrailingIdlePrompt("PS C:\\old>\rPS C:\\new>"),
"PS C:\\new>",
);
});
test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alikes", () => {
assert.equal(isDefaultPowerShellPromptLine("PS C:\\Users\\alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS /home/alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS >"), false);
assert.equal(isDefaultPowerShellPromptLine("PSO>"), false);
assert.equal(isDefaultPowerShellPromptLine("ZIPS>"), false);
assert.equal(isDefaultPowerShellPromptLine(""), false);
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};
const prompt = trackSessionIdlePrompt(session, "Last login...\r\nPS C:\\Windows\\System32>");
assert.equal(prompt, "PS C:\\Windows\\System32>");
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
assert.equal(typeof session.lastIdlePromptAt, "number");
});
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "Microsoft Windows...\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail has moved on (e.g. exited PowerShell)", () => {
// Simulates: SSH session entered PowerShell, captured `PS C:\>`, then
// user `exit`-ed back into a shell with a custom prompt the regex
// doesn't recognize. lastIdlePrompt is still the old PS line, but the
// visible tail now shows the new prompt — we must NOT keep handing
// the stale value to resolveEffectiveShellKind.
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\nexit\r\nlogout\r\n ",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail switched to cmd.exe", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\ncmd\r\nMicrosoft Windows...\r\nC:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt tolerates ANSI colour codes that wrap the prompt in either side", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "stuff\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt returns empty string when the session has no cached prompt or tail", () => {
assert.equal(getFreshIdlePrompt(null), "");
assert.equal(getFreshIdlePrompt(undefined), "");
assert.equal(getFreshIdlePrompt({}), "");
assert.equal(getFreshIdlePrompt({ lastIdlePrompt: "PS C:\\>" }), "");
assert.equal(
getFreshIdlePrompt({ lastIdlePrompt: "", _promptTrackTail: "anything" }),
"",
);
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
// (2) User runs `exit` and the shell now shows an unrecognized prompt.
// trackSessionIdlePrompt does not update lastIdlePrompt (the new shape
// doesn't match POSIX or PowerShell regexes), so the cache is stale.
trackSessionIdlePrompt(session, "\r\nexit\r\nlogout\r\n ");
assert.equal(session.lastIdlePrompt, "PS C:\\Users\\alice>"); // unchanged
// The freshness check rescues us: the visible tail no longer ends
// with the cached PS line, so downstream wrapper selection sees "".
assert.equal(getFreshIdlePrompt(session), "");
});

View File

@@ -30,6 +30,7 @@ const {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
getFreshIdlePrompt,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
@@ -1322,7 +1323,7 @@ function registerHandlers(ipcMain) {
timeoutMs,
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);

View File

@@ -12,7 +12,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { toUnpackedAsarPath, getFreshIdlePrompt } = require("./ai/shellUtils.cjs");
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
const { safeSend } = require("./ipcUtils.cjs");
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
@@ -1493,7 +1493,7 @@ function handleExec(params) {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
expectedPrompt: session.lastIdlePrompt || "",
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -1581,7 +1581,7 @@ function handleJobStart(params) {
timeoutMs,
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,

View File

@@ -6,7 +6,11 @@
const fs = require("node:fs");
const path = require("node:path");
const { toLocalISOString, stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
const {
toLocalISOString,
wrapTerminalHtmlContent,
} = require("./sessionLogsBridge.cjs");
const { createTerminalTextRenderer } = require("./terminalLogSanitizer.cjs");
// Active log streams keyed by sessionId
const activeStreams = new Map();
@@ -42,34 +46,45 @@ function startStream(sessionId, opts) {
const date = new Date(startTime || Date.now());
const dateStr = toLocalISOString(date);
// For html format, write raw data to a temp file during streaming,
// then convert on stopStream.
// Raw logs are written directly. Txt/html logs keep terminal parser state
// in memory and write the rendered file on each flush.
const isRaw = format === "raw";
const isHtml = format === "html";
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
const ext = isRaw ? "log" : isHtml ? "html" : "txt";
const fileName = `${dateStr}.${ext}`;
const filePath = path.join(hostDir, fileName);
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
const writeStream = isRaw
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
: null;
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// Disable this stream on error to avoid cascading failures
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
if (writeStream) {
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// Disable this stream on error to avoid cascading failures
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
}
const entry = {
writeStream,
filePath,
hostDir,
format,
isRaw,
isHtml,
renderer: isRaw ? null : createTerminalTextRenderer(),
hostLabel: hostLabel || hostname || "unknown",
startTime: startTime || Date.now(),
buffer: "",
flushTimer: null,
snapshotPromise: null,
snapshotRequested: false,
snapshotDirty: false,
closing: false,
disabled: false,
};
@@ -96,14 +111,12 @@ function flushBuffer(entry) {
const data = entry.buffer;
entry.buffer = "";
if (entry.isHtml) {
// For HTML format, write raw data during streaming; convert on close
entry.writeStream.write(data);
} else if (entry.format === "raw") {
if (entry.isRaw) {
entry.writeStream.write(data);
} else {
// txt format: strip ANSI codes
entry.writeStream.write(stripAnsi(data));
entry.renderer.feed(data);
entry.snapshotDirty = true;
scheduleSnapshot(entry);
}
} catch (err) {
console.error("[SessionLogStream] Flush error:", err.message);
@@ -111,6 +124,43 @@ function flushBuffer(entry) {
}
}
function renderSnapshotContent(entry) {
return entry.isHtml
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(), entry.hostLabel, entry.startTime)
: entry.renderer.toString();
}
function scheduleSnapshot(entry) {
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
if (!entry.snapshotDirty) return;
if (entry.snapshotPromise) {
entry.snapshotRequested = true;
return;
}
entry.snapshotDirty = false;
entry.snapshotPromise = fs.promises
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
.catch((err) => {
console.error("[SessionLogStream] Snapshot write failed:", err.message);
entry.snapshotDirty = true;
})
.finally(() => {
entry.snapshotPromise = null;
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
entry.snapshotRequested = false;
scheduleSnapshot(entry);
}
});
}
async function waitForSnapshotIdle(entry) {
while (entry.snapshotPromise) {
await entry.snapshotPromise;
}
}
/**
* Append data to the session's log buffer.
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
@@ -139,6 +189,7 @@ async function stopStream(sessionId) {
const entry = activeStreams.get(sessionId);
if (!entry) return null;
activeStreams.delete(sessionId);
entry.closing = true;
// Stop periodic flush
if (entry.flushTimer) {
@@ -148,34 +199,25 @@ async function stopStream(sessionId) {
// Flush remaining buffer
flushBuffer(entry);
await waitForSnapshotIdle(entry);
// Close the write stream and wait for it to finish
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
let finalPath = entry.filePath;
// For HTML format: read the temp raw file and convert to HTML
if (entry.isHtml && !entry.disabled) {
// Close the raw write stream and wait for it to finish.
if (entry.writeStream) {
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
} else if (!entry.disabled && entry.snapshotDirty) {
try {
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
// Remove temp file
try {
await fs.promises.unlink(entry.filePath);
} catch {
// Ignore cleanup errors
}
finalPath = htmlPath;
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8");
entry.snapshotDirty = false;
} catch (err) {
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
// Keep the raw temp file as fallback
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
entry.disabled = true;
}
}
const finalPath = entry.filePath;
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
return finalPath;
}

View File

@@ -6,6 +6,10 @@
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
const {
terminalDataToHtmlContent,
terminalDataToPlainText,
} = require("./terminalLogSanitizer.cjs");
/**
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
@@ -23,22 +27,6 @@ function toLocalISOString(date = new Date()) {
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
}
/**
* Strip ANSI escape codes from text
* Used for plain text export format
*/
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str
// OSC: ESC ] ... BEL or ESC ] ... ESC \
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\)/g, '')
// ANSI CSI / ESC sequences
// eslint-disable-next-line no-control-regex
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
// Remove remaining control chars except \n \r \t
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
/**
* Escape HTML special characters to prevent XSS
* Must be applied before converting ANSI codes to HTML spans
@@ -52,75 +40,12 @@ function escapeHtml(str) {
.replace(/'/g, "&#039;");
}
/**
* Convert terminal data to HTML with colors preserved
*/
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
// Basic ANSI to HTML conversion for common codes
const ansiToHtml = (text) => {
const colorMap = {
"30": "color: #000",
"31": "color: #c00",
"32": "color: #0c0",
"33": "color: #cc0",
"34": "color: #00c",
"35": "color: #c0c",
"36": "color: #0cc",
"37": "color: #ccc",
"90": "color: #666",
"91": "color: #f66",
"92": "color: #6f6",
"93": "color: #ff6",
"94": "color: #66f",
"95": "color: #f6f",
"96": "color: #6ff",
"97": "color: #fff",
"40": "background: #000",
"41": "background: #c00",
"42": "background: #0c0",
"43": "background: #cc0",
"44": "background: #00c",
"45": "background: #c0c",
"46": "background: #0cc",
"47": "background: #ccc",
"1": "font-weight: bold",
"3": "font-style: italic",
"4": "text-decoration: underline",
};
function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
const htmlContent = escapeHtml(plainText || "");
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
}
// First, escape HTML in the text content (not the ANSI codes)
// We do this by splitting on ANSI sequences, escaping each text part, then rejoining
// eslint-disable-next-line no-control-regex
const ansiRegex = /(\x1B\[[0-9;]*m|\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/g;
const parts = text.split(ansiRegex);
let result = parts.map((part) => {
// Check if this part is an ANSI sequence
// eslint-disable-next-line no-control-regex
if (/^\x1B/.test(part)) {
// It's an ANSI sequence, convert to HTML span or remove
const match = part.match(/^\x1B\[([0-9;]*)m$/);
if (match) {
const codes = match[1];
if (codes === "0" || codes === "") {
return "</span>";
}
const styles = codes.split(";").map((c) => colorMap[c]).filter(Boolean);
if (styles.length > 0) {
return `<span style="${styles.join("; ")}">`;
}
}
// Other ANSI sequences are stripped
return "";
}
// It's regular text, escape HTML
return escapeHtml(part);
}).join("");
return result;
};
const htmlContent = ansiToHtml(terminalData);
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
const dateStr = new Date(timestamp).toLocaleString();
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
const safeDateStr = escapeHtml(dateStr);
@@ -154,11 +79,19 @@ function terminalDataToHtml(terminalData, hostLabel, timestamp) {
Host: ${safeHostLabel}<br>
Date: ${safeDateStr}
</div>
<div class="content">${htmlContent}</div>
<div class="content">${htmlContent || ""}</div>
</body>
</html>`;
}
/**
* Convert terminal data to HTML after applying terminal text controls while
* preserving SGR styles such as color, bold, italic, and underline.
*/
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
return wrapTerminalHtmlContent(terminalDataToHtmlContent(terminalData), hostLabel, timestamp);
}
/**
* Export a session log to a file (manual export via save dialog)
*/
@@ -201,8 +134,8 @@ async function exportSessionLog(event, payload) {
// Raw format preserves ANSI codes
content = terminalData;
} else {
// Plain text - strip ANSI codes
content = stripAnsi(terminalData);
// Plain text - apply terminal text controls and remove escape sequences
content = terminalDataToPlainText(terminalData);
}
await fs.promises.writeFile(result.filePath, content, "utf8");
@@ -258,7 +191,7 @@ async function autoSaveSessionLog(event, payload) {
} else if (format === "raw") {
content = terminalData;
} else {
content = stripAnsi(terminalData);
content = terminalDataToPlainText(terminalData);
}
await fs.promises.writeFile(filePath, content, "utf8");
@@ -307,7 +240,8 @@ module.exports = {
selectSessionLogsDir,
autoSaveSessionLog,
openSessionLogsDir,
stripAnsi,
toLocalISOString,
terminalDataToHtml,
terminalPlainTextToHtml,
wrapTerminalHtmlContent,
};

View File

@@ -15,6 +15,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
@@ -1182,6 +1183,7 @@ async function startSSHSession(event, options) {
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
let settled = false;
let detachX11Forwarding = null;
conn.once("handshake", () => {
console.log(`${logPrefix} ${options.hostname} handshake complete`);
@@ -1202,26 +1204,57 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
sendProgress(totalHops, totalHops, options.hostname, 'shell');
const sendTerminalMessage = (data) => {
safeSend(event.sender, "netcatty:data", { sessionId, data });
};
const x11FakeCookie = options.x11Forwarding
? crypto.randomBytes(16).toString("hex")
: null;
if (options.x11Forwarding) {
detachX11Forwarding = attachX11Forwarding(conn, {
display: options.x11Display,
fakeCookie: x11FakeCookie,
sendMessage: sendTerminalMessage,
});
}
const shellOptions = {
env: {
LANG: resolveLangFromCharset(options.charset),
COLORTERM: "truecolor",
...(options.env || {}),
},
};
if (options.x11Forwarding) {
shellOptions.x11 = {
protocol: "MIT-MAGIC-COOKIE-1",
cookie: x11FakeCookie,
screen: 0,
single: false,
};
}
conn.shell(
{
term: "xterm-256color",
cols,
rows,
},
{
env: {
LANG: resolveLangFromCharset(options.charset),
COLORTERM: "truecolor",
...(options.env || {}),
},
},
shellOptions,
(err, stream) => {
if (err) {
if (detachX11Forwarding) detachX11Forwarding();
settled = true;
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
if (options.x11Forwarding && /x11/i.test(err.message || "")) {
sendTerminalMessage("\r\n[X11] Could not enable X11 forwarding. Make sure X11 forwarding is allowed on the server and xauth is installed.\r\n");
}
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
reject(err);
return;
@@ -1349,6 +1382,10 @@ async function startSSHSession(event, options) {
}
flushBuffer();
sessionLogStreamManager.stopStream(sessionId);
if (detachX11Forwarding) {
detachX11Forwarding();
detachX11Forwarding = null;
}
// Only send exit if session hasn't already been cleaned up by
// conn.once("close") — which fires before stream.on("close")
@@ -1431,6 +1468,10 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
if (detachX11Forwarding) {
detachX11Forwarding();
detachX11Forwarding = null;
}
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);

View File

@@ -117,6 +117,102 @@ function createPtyBuffer(sendFn) {
return { bufferData, flush };
}
/**
* Locate an executable on POSIX systems by name.
*
* macOS GUI Electron apps inherit launchd's minimal PATH
* (`/usr/bin:/bin:/usr/sbin:/sbin`), missing Homebrew and other common
* package-manager directories. `pty.spawn(name)` then either fails
* synchronously with ENOENT or spawns a child that immediately exits
* with no useful error surfaced to the renderer (see issue #842 for the
* Mosh case).
*
* Returns the absolute path on success, or null when the binary cannot
* be located anywhere we know to look. Win32 callers should keep using
* findExecutable() which handles `where.exe` + Windows-specific paths.
*/
const POSIX_EXTRA_PATH_DIRS = [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/opt/local/bin",
"/opt/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
];
function isExecutableFile(candidate) {
try {
const st = fs.statSync(candidate);
if (!st.isFile()) return false;
// Windows has no POSIX execute bit — Node returns mode 0o100666 even for
// .exe / .bat / .cmd files, so 0o111 is unreliable there. Treat any
// regular file as executable on Win32 and let spawn-time PATHEXT /
// extension handling reject non-executables.
if (process.platform === "win32") return true;
return (st.mode & 0o111) !== 0;
} catch {
return false;
}
}
function resolvePosixExecutable(name, opts = {}) {
if (process.platform === "win32") return null;
if (!name || typeof name !== "string") return null;
// Already an absolute or relative path: validate as-is.
if (name.includes("/")) {
return isExecutableFile(name) ? name : null;
}
if (!/^[a-zA-Z0-9._+-]+$/.test(name)) return null;
const seen = new Set();
const dirs = [];
// 1. Honor the caller-supplied PATH first so callers that have already
// merged a host-level environmentVariables.PATH override don't see the
// fallback decline a binary that the spawned process would have found.
// Falls back to the main process PATH when no override is provided.
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
? opts.pathOverride
: process.env.PATH;
for (const dir of (pathOverride || "").split(":")) {
if (dir && !seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
// 2. Add directories the GUI launcher's PATH typically misses on macOS/Linux.
for (const dir of POSIX_EXTRA_PATH_DIRS) {
if (!seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
// 3. User-scoped install locations (nix-profile, cargo, ~/.local).
const home = process.env.HOME;
if (home) {
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
const dir = path.join(home, sub);
if (!seen.has(dir)) {
seen.add(dir);
dirs.push(dir);
}
}
}
for (const dir of dirs) {
const candidate = path.join(dir, name);
if (isExecutableFile(candidate)) return candidate;
}
return null;
}
/**
* Find executable path on Windows
*/
@@ -691,13 +787,68 @@ async function startMoshSession(event, options) {
const cols = options.cols || 80;
const rows = options.rows || 24;
let moshCmd = 'mosh';
if (process.platform === 'win32') {
moshCmd = findExecutable('mosh') || 'mosh.exe';
// Resolve the mosh client to an absolute path before spawning. Bare names
// rely on the spawn-time PATH search, which on macOS GUI apps is reduced to
// `/usr/bin:/bin:/usr/sbin:/sbin` and silently fails for Homebrew installs
// (see issue #842). On Windows keep the existing behaviour.
//
// Resolution must consider the same PATH the spawned process will see —
// host-level `environmentVariables.PATH` is merged into the child env
// below, so the resolver checks that merged value first to avoid
// rejecting a binary the child would actually have found.
const optionsEnv = options.env || {};
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
? optionsEnv.PATH
: process.env.PATH;
let moshCmd;
let resolvedMoshDir = null;
// 1. Honor user-supplied moshClientPath (Settings → Terminal → Mosh).
// Strict failure: a missing/non-executable file produces a clear error
// instead of silently falling back, so users notice typos / stale paths.
const explicitClient = typeof options.moshClientPath === "string" ? options.moshClientPath.trim() : "";
if (explicitClient) {
const expanded = expandHomePath(explicitClient);
// Reject relative paths up front. validatePath in the renderer is shared
// with localShell and resolves bare names through PATH (so "mosh.exe"
// would look valid in the UI), but here moshClientPath is taken as a
// literal filesystem path and any non-absolute value would be resolved
// against the app's cwd and silently fail.
if (!path.isAbsolute(expanded)) {
throw new Error(
`Mosh client path must be absolute: "${explicitClient}". Use Settings → Terminal → Mosh to pick the binary, leave it empty to auto-detect, or enter an absolute path.`,
);
}
if (!isExecutableFile(expanded)) {
throw new Error(
`Configured Mosh client not usable: ${explicitClient}. Update Settings → Terminal → Mosh, leave it empty to auto-detect, or pick another binary.`,
);
}
moshCmd = path.resolve(expanded);
// Always remember the directory so we can extend PATH and locate
// mosh-client / ssh helpers regardless of platform — Windows
// installs outside %PATH% otherwise can't resolve siblings even
// though the wrapper itself runs.
resolvedMoshDir = path.dirname(moshCmd);
} else if (process.platform === "win32") {
moshCmd = findExecutable("mosh") || "mosh.exe";
} else {
const resolved = resolvePosixExecutable("mosh", { pathOverride: mergedPathForResolution });
if (!resolved) {
const installHint =
process.platform === "darwin"
? "macOS: brew install mosh"
: "Linux: sudo apt install mosh / sudo dnf install mosh / sudo pacman -S mosh";
throw new Error(
`Mosh client not found on PATH. Install it (${installHint}) or place the 'mosh' binary somewhere on PATH such as /opt/homebrew/bin or /usr/local/bin. You can also point Settings → Terminal → Mosh at an absolute path.`,
);
}
moshCmd = resolved;
resolvedMoshDir = path.dirname(resolved);
}
const args = [];
if (options.port && options.port !== 22) {
args.push('--ssh=ssh -p ' + options.port);
}
@@ -706,7 +857,7 @@ async function startMoshSession(event, options) {
args.push('--server=' + options.moshServerPath);
}
const userHost = options.username
const userHost = options.username
? `${options.username}@${options.hostname}`
: options.hostname;
args.push(userHost);
@@ -722,11 +873,40 @@ async function startMoshSession(event, options) {
const env = {
...process.env,
...(options.env || {}),
...optionsEnv,
TERM: 'xterm-256color',
LANG: resolveLangFromCharset(options.charset),
};
// The mosh wrapper is a Perl script that exec's `mosh-client` (and `ssh`)
// by name, so it needs them on PATH. Prepend the resolved mosh's directory
// to the env PATH (typical layout: mosh + mosh-client live side by side).
// Also point MOSH_CLIENT at the absolute mosh-client when present, so the
// wrapper picks it up even if PATH is overridden downstream.
if (resolvedMoshDir) {
const sep = path.delimiter; // ":" on POSIX, ";" on Win32
const existingPath = env.PATH || "";
const onPath = existingPath
.split(sep)
.some((p) => p && path.normalize(p) === path.normalize(resolvedMoshDir));
if (!onPath) {
env.PATH = existingPath ? `${resolvedMoshDir}${sep}${existingPath}` : resolvedMoshDir;
}
if (!env.MOSH_CLIENT) {
const clientCandidates =
process.platform === "win32"
? ["mosh-client.exe", "mosh-client.bat", "mosh-client.cmd", "mosh-client"]
: ["mosh-client"];
for (const name of clientCandidates) {
const candidate = path.join(resolvedMoshDir, name);
if (isExecutableFile(candidate)) {
env.MOSH_CLIENT = candidate;
break;
}
}
}
}
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
@@ -1093,6 +1273,8 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:mosh:detectClient", () => detectMoshClient());
ipcMain.handle("netcatty:mosh:pickClient", () => pickMoshClient());
ipcMain.handle("netcatty:serial:start", startSerialSession);
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
@@ -1115,29 +1297,42 @@ function getDefaultShell() {
* Validate a path - check if it exists and whether it's a file or directory
* @param {object} event - IPC event
* @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' }
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }}
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean, isExecutable: boolean }}
*
* `isExecutable` mirrors isExecutableFile(): POSIX requires the file mode
* to have an execute bit; Win32 treats any regular file as executable
* (NTFS lacks POSIX bits — extension/PATHEXT decides at spawn time).
* Existing callers ignore the new field; consumers that need exec
* semantics (e.g. Mosh client path) read it explicitly.
*/
function statIsExecutable(stat) {
if (!stat || !stat.isFile()) return false;
if (process.platform === "win32") return true;
return (stat.mode & 0o111) !== 0;
}
function validatePath(event, payload) {
const targetPath = payload?.path;
const type = payload?.type || 'any';
if (!targetPath) {
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
}
try {
// Resolve path (handle ~, etc.)
let resolvedPath = expandHomePath(targetPath);
resolvedPath = path.resolve(resolvedPath);
if (fs.existsSync(resolvedPath)) {
const stat = fs.statSync(resolvedPath);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
// If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe)
if (type === 'file') {
const resolvedExecutable = findExecutable(targetPath);
@@ -1148,6 +1343,7 @@ function validatePath(event, payload) {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
// Also try with .exe extension on Windows if not already present
@@ -1159,18 +1355,85 @@ function validatePath(event, payload) {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isExecutable: statIsExecutable(stat),
};
}
}
}
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
} catch (err) {
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
return { exists: false, isFile: false, isDirectory: false };
return { exists: false, isFile: false, isDirectory: false, isExecutable: false };
}
}
/**
* Run the same auto-discovery startMoshSession uses, surfacing the result
* (and the search list when nothing was found) to the Settings UI.
*/
function detectMoshClient() {
if (process.platform === "win32") {
const resolved = findExecutable("mosh");
const found = !!resolved && resolved !== "mosh" && fs.existsSync(resolved);
return {
platform: "win32",
found,
path: found ? resolved : null,
searchedPaths: [],
};
}
const dirs = [];
const seen = new Set();
for (const dir of (process.env.PATH || "").split(":")) {
if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
for (const dir of POSIX_EXTRA_PATH_DIRS) {
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
const home = process.env.HOME;
if (home) {
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
const dir = path.join(home, sub);
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
}
}
const resolved = resolvePosixExecutable("mosh");
return {
platform: process.platform,
found: !!resolved,
path: resolved,
searchedPaths: dirs,
};
}
/**
* Open a native file picker so the user can select a Mosh client binary.
* Returns { canceled, filePath } so the renderer can decide what to do.
*/
async function pickMoshClient() {
const { dialog, BrowserWindow } = electronModule || {};
if (!dialog) {
return { canceled: true, filePath: null };
}
const win = BrowserWindow?.getFocusedWindow?.() || undefined;
const isWin = process.platform === "win32";
const result = await dialog.showOpenDialog(win, {
title: "Select Mosh client",
properties: ["openFile", "showHiddenFiles"],
filters: isWin
? [
{ name: "Executables", extensions: ["exe", "bat", "cmd"] },
{ name: "All Files", extensions: ["*"] },
]
: [{ name: "All Files", extensions: ["*"] }],
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { canceled: true, filePath: null };
}
return { canceled: false, filePath: result.filePaths[0] };
}
/**
* Cleanup all sessions - call before app quit
*/
@@ -1220,6 +1483,8 @@ module.exports = {
startLocalSession,
startTelnetSession,
startMoshSession,
detectMoshClient,
pickMoshClient,
startSerialSession,
listSerialPorts,
writeToSession,

View File

@@ -0,0 +1,453 @@
/**
* Terminal log sanitizer.
*
* This is intentionally stateful: terminal output is a stream of cursor and
* erase operations, not plain text with decoration. The renderer below keeps a
* small virtual text buffer so plain-text and HTML logs reflect what common
* line-editing output actually leaves on screen.
*/
const CSI_FINAL_RE = /[@-~]/;
const DEFAULT_FOREGROUND = "#d4d4d4";
const DEFAULT_BACKGROUND = "#1e1e1e";
const BASIC_COLORS = [
"#000000",
"#cd3131",
"#0dbc79",
"#e5e510",
"#2472c8",
"#bc3fbc",
"#11a8cd",
"#e5e5e5",
];
const BRIGHT_COLORS = [
"#666666",
"#f14c4c",
"#23d18b",
"#f5f543",
"#3b8eea",
"#d670d6",
"#29b8db",
"#ffffff",
];
class TerminalTextRenderer {
constructor() {
this.lines = [[]];
this.row = 0;
this.col = 0;
this.state = "normal";
this.escapeBuffer = "";
this.style = createDefaultStyle();
}
feed(input) {
if (!input) return;
for (const ch of input) {
this.#consume(ch);
}
}
finish() {
this.state = "normal";
this.escapeBuffer = "";
return this.toString();
}
toString() {
return this.lines
.map((line) => line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""))
.join("\n")
.replace(/\n+$/g, "");
}
toHtmlContent() {
return this.lines
.map((line) => renderLineHtml(line))
.join("\n")
.replace(/\n+$/g, "");
}
#consume(ch) {
if (this.state === "esc") {
this.#consumeEsc(ch);
return;
}
if (this.state === "csi") {
this.escapeBuffer += ch;
if (CSI_FINAL_RE.test(ch)) {
this.#applyCsi(this.escapeBuffer);
this.state = "normal";
this.escapeBuffer = "";
}
return;
}
if (this.state === "osc") {
if (ch === "\x07") {
this.state = "normal";
this.escapeBuffer = "";
return;
}
if (ch === "\x1b") {
this.state = "oscEsc";
}
return;
}
if (this.state === "oscEsc") {
this.state = ch === "\\" ? "normal" : "osc";
return;
}
switch (ch) {
case "\x1b":
this.state = "esc";
this.escapeBuffer = "";
break;
case "\b":
this.col = Math.max(0, this.col - 1);
break;
case "\r":
this.col = 0;
break;
case "\n":
this.row += 1;
this.col = 0;
this.#ensureLine();
break;
case "\t":
this.#writeText(" ".repeat(8 - (this.col % 8)));
break;
default:
if (this.#isPrintable(ch)) this.#writeText(ch);
break;
}
}
#consumeEsc(ch) {
if (ch === "[") {
this.state = "csi";
this.escapeBuffer = "";
return;
}
if (ch === "]") {
this.state = "osc";
this.escapeBuffer = "";
return;
}
// Single-character ESC sequences are terminal controls. Ignore them for
// logs, but consume them so they never leak into txt/html output.
this.state = "normal";
this.escapeBuffer = "";
}
#applyCsi(sequence) {
const final = sequence.at(-1);
const params = sequence.slice(0, -1);
const values = params
.replace(/[?><=]/g, "")
.split(";")
.map((part) => {
if (part === "") return undefined;
const n = Number.parseInt(part, 10);
return Number.isFinite(n) ? n : undefined;
});
const n = values[0] || 1;
switch (final) {
case "A":
this.row = Math.max(0, this.row - n);
this.#ensureLine();
break;
case "B":
case "E":
this.row += n;
if (final === "E") this.col = 0;
this.#ensureLine();
break;
case "C":
this.col += n;
break;
case "D":
this.col = Math.max(0, this.col - n);
break;
case "F":
this.row = Math.max(0, this.row - n);
this.col = 0;
this.#ensureLine();
break;
case "G":
this.col = Math.max(0, n - 1);
break;
case "H":
case "f":
this.row = Math.max(0, (values[0] || 1) - 1);
this.col = Math.max(0, (values[1] || 1) - 1);
this.#ensureLine();
break;
case "J":
this.#eraseDisplay(values[0] || 0);
break;
case "K":
this.#eraseLine(values[0] || 0);
break;
case "m":
this.#applySgr(values);
break;
default:
// Unsupported CSI controls are intentionally ignored.
break;
}
}
#applySgr(values) {
const codes = values.length > 0 ? values : [0];
for (let i = 0; i < codes.length; i += 1) {
const code = codes[i] ?? 0;
if (code === 0) {
this.style = createDefaultStyle();
} else if (code === 1) {
this.style.bold = true;
} else if (code === 3) {
this.style.italic = true;
} else if (code === 4) {
this.style.underline = true;
} else if (code === 7) {
this.style.inverse = true;
} else if (code === 22) {
this.style.bold = false;
} else if (code === 23) {
this.style.italic = false;
} else if (code === 24) {
this.style.underline = false;
} else if (code === 27) {
this.style.inverse = false;
} else if (code >= 30 && code <= 37) {
this.style.fg = BASIC_COLORS[code - 30];
} else if (code === 39) {
this.style.fg = null;
} else if (code >= 40 && code <= 47) {
this.style.bg = BASIC_COLORS[code - 40];
} else if (code === 49) {
this.style.bg = null;
} else if (code >= 90 && code <= 97) {
this.style.fg = BRIGHT_COLORS[code - 90];
} else if (code >= 100 && code <= 107) {
this.style.bg = BRIGHT_COLORS[code - 100];
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
const color = colorFromAnsi256(codes[i + 2]);
if (color) {
if (code === 38) this.style.fg = color;
else this.style.bg = color;
}
i += 2;
} else if ((code === 38 || code === 48) && codes[i + 1] === 2) {
const color = colorFromRgb(codes[i + 2], codes[i + 3], codes[i + 4]);
if (color) {
if (code === 38) this.style.fg = color;
else this.style.bg = color;
}
i += 4;
}
}
}
#writeText(text) {
this.#ensureLine();
const line = this.lines[this.row];
while (line.length < this.col) line.push(createCell(" ", createDefaultStyle()));
for (const ch of text) {
line[this.col] = createCell(ch, this.style);
this.col += 1;
}
}
#eraseLine(mode) {
this.#ensureLine();
const line = this.lines[this.row];
if (mode === 1) {
for (let i = 0; i <= this.col && i < line.length; i += 1) {
line[i] = createCell(" ", createDefaultStyle());
}
return;
}
if (mode === 2) {
this.lines[this.row] = [];
return;
}
line.length = Math.min(line.length, this.col);
}
#eraseDisplay(mode) {
this.#ensureLine();
if (mode === 2 || mode === 3) {
this.lines = [[]];
this.row = 0;
this.col = 0;
return;
}
if (mode === 1) {
this.lines = this.lines.slice(this.row);
this.row = 0;
this.#eraseLine(1);
return;
}
this.#eraseLine(0);
this.lines.length = this.row + 1;
}
#ensureLine() {
while (this.lines.length <= this.row) this.lines.push([]);
}
#isPrintable(ch) {
const code = ch.codePointAt(0);
if (code === undefined) return false;
return code >= 0x20 && code !== 0x7f;
}
}
function terminalDataToPlainText(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
return renderer.finish();
}
function terminalDataToHtmlContent(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
renderer.finish();
return renderer.toHtmlContent();
}
function createTerminalTextRenderer() {
return new TerminalTextRenderer();
}
module.exports = {
TerminalTextRenderer,
createTerminalTextRenderer,
terminalDataToHtmlContent,
terminalDataToPlainText,
};
function createDefaultStyle() {
return {
fg: null,
bg: null,
bold: false,
italic: false,
underline: false,
inverse: false,
};
}
function createCell(ch, style) {
return {
ch,
style: { ...style },
};
}
function renderLineHtml(line) {
let html = "";
let runText = "";
let runStyle = null;
const flush = () => {
if (!runText) return;
const escaped = escapeHtml(runText);
const style = styleToCss(runStyle);
html += style ? `<span style="${style}">${escaped}</span>` : escaped;
runText = "";
};
const trimmedLength = getTrimmedLineLength(line);
for (let i = 0; i < trimmedLength; i += 1) {
const cell = line[i] || createCell(" ", createDefaultStyle());
if (!runStyle || !stylesEqual(runStyle, cell.style)) {
flush();
runStyle = cell.style;
}
runText += cell.ch;
}
flush();
return html;
}
function getTrimmedLineLength(line) {
let length = line.length;
while (length > 0) {
const cell = line[length - 1];
const ch = cell?.ch || " ";
if (ch !== " " && ch !== "\t") break;
if (styleToCss(cell?.style)) break;
length -= 1;
}
return length;
}
function styleToCss(style) {
if (!style) return "";
const declarations = [];
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
if (fg) declarations.push(`color: ${fg}`);
if (bg) declarations.push(`background-color: ${bg}`);
if (style.bold) declarations.push("font-weight: 700");
if (style.italic) declarations.push("font-style: italic");
if (style.underline) declarations.push("text-decoration: underline");
return declarations.join("; ");
}
function stylesEqual(a, b) {
return (
a.fg === b.fg &&
a.bg === b.bg &&
a.bold === b.bold &&
a.italic === b.italic &&
a.underline === b.underline &&
a.inverse === b.inverse
);
}
function colorFromAnsi256(value) {
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
if (value < 8) return BASIC_COLORS[value];
if (value < 16) return BRIGHT_COLORS[value - 8];
if (value < 232) {
const n = value - 16;
const r = Math.floor(n / 36);
const g = Math.floor((n % 36) / 6);
const b = n % 6;
return colorFromRgb(colorCubeValue(r), colorCubeValue(g), colorCubeValue(b));
}
const level = 8 + (value - 232) * 10;
return colorFromRgb(level, level, level);
}
function colorCubeValue(n) {
return n === 0 ? 0 : 55 + n * 40;
}
function colorFromRgb(r, g, b) {
if (![r, g, b].every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
return null;
}
return `#${hex2(r)}${hex2(g)}${hex2(b)}`;
}
function hex2(value) {
return value.toString(16).padStart(2, "0");
}
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -0,0 +1,319 @@
const net = require("node:net");
const fs = require("node:fs");
const { Transform } = require("node:stream");
const { execFileSync } = require("node:child_process");
const X11_PORT_BASE = 6000;
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
function resolveX11DisplaySpec(spec, options = {}) {
const platform = options.platform || process.platform;
let raw = String(spec || options.envDisplay || process.env.DISPLAY || (platform === "win32" ? "localhost:0" : ":0")).trim();
if (!raw) {
return resolveX11DisplaySpec(undefined, { ...options, envDisplay: platform === "win32" ? "localhost:0" : ":0" });
}
if (raw === ":") {
raw = ":0";
}
if (raw.startsWith("/")) {
return { path: raw };
}
const match = raw.match(/^(.*):(\d+)(?:\.(\d+))?$/);
if (!match) {
return platform === "win32"
? { host: raw, port: X11_PORT_BASE }
: { path: raw };
}
const host = match[1] || "";
const display = Number.parseInt(match[2], 10);
const port = display >= 100 ? display : X11_PORT_BASE + display;
if (host.toLowerCase() === "unix" && platform !== "win32") {
return { path: `/tmp/.X11-unix/X${display}` };
}
if (!host) {
if (platform === "win32") {
return { host: "localhost", port };
}
return { path: `/tmp/.X11-unix/X${display}` };
}
if (host.startsWith("/")) {
return { path: host };
}
return { host, port };
}
function formatDisplayTarget(target) {
if (target.path) return target.path;
return `${target.host}:${target.port}`;
}
function platformHint(platform) {
if (platform === "win32") {
return "Install and start VcXsrv or Xming, then try again.";
}
if (platform === "darwin") {
return "Install and start XQuartz, then try again.";
}
return "Check DISPLAY and make sure Xorg, Xwayland, or your X server is running.";
}
function connectSocket(socket, target) {
if (target.path) {
return socket.connect(target.path);
}
return socket.connect(target.port, target.host);
}
function destroyStream(stream) {
try {
stream.destroy();
} catch {
// best effort cleanup
}
}
function pad4(n) {
return (n + 3) & ~3;
}
function readUInt16(buf, offset, littleEndian) {
return littleEndian ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset);
}
function normalizeCookieBuffer(cookie) {
if (!cookie) return null;
if (Buffer.isBuffer(cookie)) return cookie;
const value = String(cookie).trim();
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
return Buffer.from(value, "hex");
}
return Buffer.from(value, "binary");
}
function rewriteX11AuthSetupPacket(buffer, options = {}) {
const fakeCookie = normalizeCookieBuffer(options.fakeCookie);
const realCookie = normalizeCookieBuffer(options.realCookie);
if (!realCookie || buffer.length < 12) {
return { buffer, complete: buffer.length >= 12, rewritten: false };
}
const byteOrder = buffer[0];
const littleEndian = byteOrder === 0x6c; // 'l'
if (!littleEndian && byteOrder !== 0x42) { // 'B'
return { buffer, complete: true, rewritten: false };
}
const protocolLength = readUInt16(buffer, 6, littleEndian);
const dataLength = readUInt16(buffer, 8, littleEndian);
const protocolStart = 12;
const dataStart = protocolStart + pad4(protocolLength);
const totalLength = dataStart + pad4(dataLength);
if (buffer.length < totalLength) {
return { buffer, complete: false, rewritten: false };
}
const protocol = buffer.subarray(protocolStart, protocolStart + protocolLength).toString("ascii");
if (protocol !== MIT_MAGIC_COOKIE_PROTOCOL || dataLength !== realCookie.length) {
return { buffer, complete: true, rewritten: false };
}
const dataEnd = dataStart + dataLength;
const currentCookie = buffer.subarray(dataStart, dataEnd);
if (fakeCookie && currentCookie.length === fakeCookie.length && !currentCookie.equals(fakeCookie)) {
return { buffer, complete: true, rewritten: false };
}
const next = Buffer.from(buffer);
realCookie.copy(next, dataStart);
return { buffer: next, complete: true, rewritten: true };
}
function createX11AuthTransform(options = {}) {
let pending = Buffer.alloc(0);
let done = false;
return new Transform({
transform(chunk, _encoding, callback) {
if (done) {
callback(null, chunk);
return;
}
pending = Buffer.concat([pending, Buffer.from(chunk)]);
const result = rewriteX11AuthSetupPacket(pending, options);
if (!result.complete) {
callback();
return;
}
done = true;
callback(null, result.buffer);
},
flush(callback) {
if (!done && pending.length > 0) {
callback(null, pending);
return;
}
callback();
},
});
}
function resolveXauthCommand(platform) {
if (platform === "darwin" && fs.existsSync("/opt/X11/bin/xauth")) {
return "/opt/X11/bin/xauth";
}
return "xauth";
}
function getDisplayNumber(display) {
const value = String(display || process.env.DISPLAY || ":0").trim() || ":0";
const normalized = value === ":" ? ":0" : value;
const match = normalized.match(/:(\d+)(?:\.\d+)?$/) || normalized.match(/\/X(\d+)$/);
if (!match) return null;
const displayNumber = Number.parseInt(match[1], 10);
if (!Number.isFinite(displayNumber)) return null;
return displayNumber >= X11_PORT_BASE ? displayNumber - X11_PORT_BASE : displayNumber;
}
function parseXauthCookie(output, display) {
const requestedDisplay = getDisplayNumber(display);
const cookiePattern = new RegExp(`\\b${MIT_MAGIC_COOKIE_PROTOCOL}\\b\\s+([0-9a-fA-F]+)`);
for (const entry of String(output || "").split(/\r?\n/)) {
const match = entry.match(cookiePattern);
if (!match) continue;
const target = entry.trim().split(/\s+/, 1)[0];
if (requestedDisplay !== null && getDisplayNumber(target) !== requestedDisplay) {
continue;
}
return Buffer.from(match[1], "hex");
}
return null;
}
function readLocalX11AuthCookie(options = {}) {
const platform = options.platform || process.platform;
const command = options.xauthCommand || resolveXauthCommand(platform);
const display = String(options.display || process.env.DISPLAY || ":0").trim() || ":0";
try {
const normalizedDisplay = display === ":" ? ":0" : display;
const output = typeof options.readXauthOutput === "function"
? options.readXauthOutput({ command, display: normalizedDisplay })
: execFileSync(command, ["list"], {
encoding: "utf8",
env: {
...process.env,
DISPLAY: normalizedDisplay,
},
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000,
});
return parseXauthCookie(output, normalizedDisplay);
} catch {
return null;
}
}
function attachX11Forwarding(conn, options = {}) {
const createSocket = options.createSocket || (() => new net.Socket());
const sendMessage = typeof options.sendMessage === "function" ? options.sendMessage : () => {};
const logger = options.logger || console;
const platform = options.platform || process.platform;
const display = options.display;
const fakeCookie = options.fakeCookie;
const fixedLocalAuthCookie = normalizeCookieBuffer(options.localAuthCookie);
let localAuthCookie = fixedLocalAuthCookie;
let localAuthCookieResolved = Boolean(fixedLocalAuthCookie);
const resolveLocalAuthCookie = () => {
if (localAuthCookieResolved) return localAuthCookie;
localAuthCookieResolved = true;
const cookie = typeof options.readLocalAuthCookie === "function"
? options.readLocalAuthCookie({ display, platform })
: readLocalX11AuthCookie({ display, platform });
localAuthCookie = normalizeCookieBuffer(cookie);
return localAuthCookie;
};
const onX11 = (info, accept, reject) => {
const target = resolveX11DisplaySpec(display, { platform });
const localSocket = createSocket();
let acceptedChannel = null;
let settled = false;
const cleanup = () => {
if (acceptedChannel) destroyStream(acceptedChannel);
destroyStream(localSocket);
};
localSocket.once("connect", () => {
if (settled) return;
try {
acceptedChannel = accept();
settled = true;
} catch (err) {
logger.warn?.("[X11] Failed to accept forwarded channel", err);
cleanup();
return;
}
acceptedChannel.on("error", () => cleanup());
localSocket.on("error", () => cleanup());
acceptedChannel.on("close", () => destroyStream(localSocket));
localSocket.on("close", () => destroyStream(acceptedChannel));
const realCookie = resolveLocalAuthCookie();
if (realCookie && fakeCookie) {
acceptedChannel
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
.pipe(localSocket)
.pipe(acceptedChannel);
} else {
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
}
});
localSocket.once("error", (err) => {
if (!settled) {
settled = true;
try { reject(); } catch { /* ignore reject errors */ }
sendMessage(`\r\n[X11] Could not connect to the local X11 server: ${err?.message || err}\r\n`);
sendMessage(`[X11] Display target: ${formatDisplayTarget(target)}\r\n`);
sendMessage(`[X11] ${platformHint(platform)}\r\n`);
}
destroyStream(localSocket);
});
try {
connectSocket(localSocket, target);
} catch (err) {
localSocket.emit("error", err);
}
};
conn.on("x11", onX11);
return () => {
if (typeof conn.off === "function") conn.off("x11", onX11);
else conn.removeListener("x11", onX11);
};
}
module.exports = {
attachX11Forwarding,
readLocalX11AuthCookie,
rewriteX11AuthSetupPacket,
resolveX11DisplaySpec,
};

View File

@@ -0,0 +1,235 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { Duplex } = require("node:stream");
const { EventEmitter } = require("node:events");
const {
attachX11Forwarding,
readLocalX11AuthCookie,
rewriteX11AuthSetupPacket,
resolveX11DisplaySpec,
} = require("./x11Forwarding.cjs");
const buildX11SetupPacket = ({ cookie, endian = "l" }) => {
const protocol = Buffer.from("MIT-MAGIC-COOKIE-1", "ascii");
const cookieBytes = Buffer.from(cookie, "hex");
const protocolPad = (4 - (protocol.length % 4)) % 4;
const cookiePad = (4 - (cookieBytes.length % 4)) % 4;
const packet = Buffer.alloc(12 + protocol.length + protocolPad + cookieBytes.length + cookiePad);
packet[0] = endian.charCodeAt(0);
const writeUInt16 = endian === "l"
? packet.writeUInt16LE.bind(packet)
: packet.writeUInt16BE.bind(packet);
writeUInt16(11, 2);
writeUInt16(0, 4);
writeUInt16(protocol.length, 6);
writeUInt16(cookieBytes.length, 8);
protocol.copy(packet, 12);
cookieBytes.copy(packet, 12 + protocol.length + protocolPad);
return packet;
};
test("resolveX11DisplaySpec maps unix display to the X11 socket path", () => {
assert.deepEqual(
resolveX11DisplaySpec(":2", { platform: "linux" }),
{ path: "/tmp/.X11-unix/X2" },
);
});
test("resolveX11DisplaySpec treats a bare colon as display zero", () => {
assert.deepEqual(
resolveX11DisplaySpec(":", { platform: "darwin" }),
{ path: "/tmp/.X11-unix/X0" },
);
});
test("resolveX11DisplaySpec maps tcp display numbers to X11 ports", () => {
assert.deepEqual(
resolveX11DisplaySpec("localhost:1", { platform: "win32" }),
{ host: "localhost", port: 6001 },
);
});
test("resolveX11DisplaySpec accepts explicit unix socket paths", () => {
assert.deepEqual(
resolveX11DisplaySpec("/private/tmp/com.apple.launchd.test/org.xquartz:0", { platform: "darwin" }),
{ path: "/private/tmp/com.apple.launchd.test/org.xquartz:0" },
);
});
test("resolveX11DisplaySpec maps unix-prefixed displays to local X11 sockets", () => {
assert.deepEqual(
resolveX11DisplaySpec("unix:1", { platform: "linux" }),
{ path: "/tmp/.X11-unix/X1" },
);
});
test("rewriteX11AuthSetupPacket replaces the SSH fake cookie with the local X11 cookie", () => {
const fakeCookie = "11111111111111111111111111111111";
const realCookie = "22222222222222222222222222222222";
const rewritten = rewriteX11AuthSetupPacket(buildX11SetupPacket({ cookie: fakeCookie }), {
fakeCookie,
realCookie: Buffer.from(realCookie, "hex"),
});
assert.equal(rewritten.complete, true);
assert.equal(rewritten.rewritten, true);
assert.match(rewritten.buffer.toString("hex"), new RegExp(realCookie));
assert.doesNotMatch(rewritten.buffer.toString("hex"), new RegExp(fakeCookie));
});
test("readLocalX11AuthCookie selects the cookie for the requested display", () => {
const cookie0 = "00000000000000000000000000000000";
const cookie10 = "10101010101010101010101010101010";
const cookie = readLocalX11AuthCookie({
display: ":0",
readXauthOutput: () => [
`host/unix:10 MIT-MAGIC-COOKIE-1 ${cookie10}`,
`host/unix:0 MIT-MAGIC-COOKIE-1 ${cookie0}`,
].join("\n"),
});
assert.equal(cookie.toString("hex"), cookie0);
});
test("readLocalX11AuthCookie matches explicit unix socket display paths", () => {
const cookie0 = "00000000000000000000000000000000";
const cookie1 = "11111111111111111111111111111111";
const cookie = readLocalX11AuthCookie({
display: "/tmp/.X11-unix/X1",
readXauthOutput: () => [
`host/unix:0 MIT-MAGIC-COOKIE-1 ${cookie0}`,
`host/unix:1 MIT-MAGIC-COOKIE-1 ${cookie1}`,
].join("\n"),
});
assert.equal(cookie.toString("hex"), cookie1);
});
test("attachX11Forwarding reuses a session-level local X11 cookie", async () => {
const conn = new EventEmitter();
const localSockets = [];
const acceptedChannels = [];
const cookieReads = [];
const makeDuplex = () => new Duplex({
read() {},
write(_chunk, _encoding, callback) {
callback();
},
});
attachX11Forwarding(conn, {
display: ":0",
fakeCookie: "11111111111111111111111111111111",
readLocalAuthCookie: () => {
cookieReads.push(Date.now());
return Buffer.from("22222222222222222222222222222222", "hex");
},
createSocket: () => {
const socket = makeDuplex();
socket.connect = () => {
queueMicrotask(() => socket.emit("connect"));
return socket;
};
localSockets.push(socket);
return socket;
},
sendMessage: () => {},
platform: "linux",
});
for (let i = 0; i < 2; i++) {
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 + i }, () => {
const channel = makeDuplex();
acceptedChannels.push(channel);
return channel;
}, () => {
throw new Error("unexpected reject");
});
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(localSockets.length, 2);
assert.equal(acceptedChannels.length, 2);
assert.equal(cookieReads.length, 1);
});
test("attachX11Forwarding pipes accepted X11 channels to the local display socket", async () => {
const conn = new EventEmitter();
const localSocket = new Duplex({
read() {},
write(chunk, _encoding, callback) {
localSocket.written = Buffer.concat([localSocket.written ?? Buffer.alloc(0), Buffer.from(chunk)]);
callback();
},
});
const acceptedChannel = new Duplex({
read() {},
write(chunk, _encoding, callback) {
acceptedChannel.written = Buffer.concat([acceptedChannel.written ?? Buffer.alloc(0), Buffer.from(chunk)]);
callback();
},
});
localSocket.connect = () => {
queueMicrotask(() => localSocket.emit("connect"));
return localSocket;
};
let accepted = false;
const messages = [];
attachX11Forwarding(conn, {
display: ":0",
createSocket: () => localSocket,
sendMessage: (message) => messages.push(message),
platform: "linux",
});
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 }, () => {
accepted = true;
return acceptedChannel;
}, () => {
throw new Error("unexpected reject");
});
await new Promise((resolve) => setImmediate(resolve));
acceptedChannel.push("remote");
localSocket.push("local");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(accepted, true);
assert.equal(localSocket.written.toString(), "remote");
assert.equal(acceptedChannel.written.toString(), "local");
assert.deepEqual(messages, []);
});
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
const conn = new EventEmitter();
const localSocket = new EventEmitter();
localSocket.connect = () => {
queueMicrotask(() => localSocket.emit("error", new Error("ECONNREFUSED")));
return localSocket;
};
localSocket.destroy = () => {};
let rejected = false;
const messages = [];
attachX11Forwarding(conn, {
display: "localhost:0",
createSocket: () => localSocket,
sendMessage: (message) => messages.push(message),
platform: "win32",
});
conn.emit("x11", { srcIP: "127.0.0.1", srcPort: 1234 }, () => {
throw new Error("unexpected accept");
}, () => {
rejected = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(rejected, true);
assert.match(messages.join("\n"), /Could not connect to the local X11 server/);
assert.match(messages.join("\n"), /VcXsrv/);
});

View File

@@ -54,7 +54,7 @@ try {
electronModule = require("electron");
}
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
const { app, BrowserWindow, Menu, protocol, shell, clipboard, session } = electronModule || {};
if (!app || !BrowserWindow) {
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
}
@@ -1078,6 +1078,77 @@ if (!gotLock) {
app.whenReady().then(() => {
registerAppProtocol();
// Grant only the Chromium permissions the app actually uses, and only
// to the app's own origin. The default session is shared with in-app
// OAuth pop-ups (accounts.google.com, login.microsoftonline.com, ...),
// so non-app origins are denied outright; for the app itself we keep
// an explicit allow-list rather than blanket-approving everything.
try {
const defaultSession = session?.defaultSession;
if (defaultSession) {
// app:// is registered as a standard scheme in Chromium
// (registerSchemesAsPrivileged above) but Node's WHATWG URL parser
// doesn't include it in its special-scheme list, so
// `new URL('app://netcatty/...').origin` returns the string "null"
// — matching against an `app://netcatty` origin string would
// therefore fail in packaged builds. Match by protocol + host
// instead, and only fall back to .origin for HTTP-family URLs
// (the dev server).
const allowedHttpOrigins = new Set();
if (effectiveDevServerUrl) {
try {
allowedHttpOrigins.add(new URL(effectiveDevServerUrl).origin);
} catch {
// ignore malformed dev server URL
}
}
const isAppOrigin = (rawUrl) => {
if (!rawUrl) return false;
try {
const parsed = new URL(String(rawUrl));
if (parsed.protocol === "app:") {
return parsed.host === "netcatty";
}
return allowedHttpOrigins.has(parsed.origin);
} catch {
return false;
}
};
// Permissions the renderer is known to need:
// - local-fonts: terminal font picker enumeration (this PR)
// - clipboard-read / clipboard-sanitized-write: terminal & SFTP
// copy-paste flows (navigator.clipboard.{read,write}Text)
const APP_ALLOWED_PERMISSIONS = new Set([
"local-fonts",
"clipboard-read",
"clipboard-sanitized-write",
]);
defaultSession.setPermissionRequestHandler((wc, permission, callback, details) => {
const requestingUrl =
details?.requestingUrl ||
(typeof wc?.getURL === "function" ? wc.getURL() : "");
if (!isAppOrigin(requestingUrl)) {
callback(false);
return;
}
callback(APP_ALLOWED_PERMISSIONS.has(permission));
});
defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
const url =
requestingOrigin ||
details?.requestingUrl ||
(typeof wc?.getURL === "function" ? wc.getURL() : "");
if (!isAppOrigin(url)) return false;
return APP_ALLOWED_PERMISSIONS.has(permission);
});
}
} catch (err) {
console.warn("[Main] Failed to install permission handlers:", err);
}
// Set dock icon on macOS
if (isMac && appIcon && app.dock?.setIcon) {
try {
@@ -1225,35 +1296,92 @@ if (!gotLock) {
}
const { ipcMain: _ipcMain } = electronModule;
const win = BrowserWindow.getAllWindows()[0];
// No window — nothing to check; commit to quit directly.
if (!win || win.isDestroyed?.()) {
// Target the main window explicitly. Falling back to
// BrowserWindow.getAllWindows()[0] could pick the tray panel or settings
// window, whose renderers don't listen for app:query-dirty-editors and
// would force the 5s timeout fallback to run on every quit.
const win = getWindowManager().getMainWindow();
// No main window, or it's hidden (tray-panel "Quit" path) — there's no
// visible UI to surface a "save first" toast on, so skip the round-trip
// and quit directly. The renderer's dirty-editor check exists to warn the
// user; if they can't see the warning, it's just dead 5-second wait.
//
// A minimized window is *not* hidden: the user has a taskbar/Dock entry
// and can restore in one click, so we still want to gate the quit on the
// dirty-editor check there. Some platforms report isVisible()=false on a
// minimized window (see globalShortcutBridge.cjs:478), so check both.
const isReachableByUser =
win && !win.isDestroyed?.() &&
(win.isVisible?.() || win.isMinimized?.());
if (!isReachableByUser) {
commitQuit();
return;
}
// The renderer needs to be alive for the IPC roundtrip to make sense.
// A crashed renderer would silently drop the message and we'd wait
// 5 s for nothing — skip straight to quit (we can't ask the user
// anyway, the UI is gone).
const wc = win.webContents;
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) {
commitQuit();
return;
}
quitGuardChannelBusy = true;
event.preventDefault();
win.webContents.send("app:query-dirty-editors");
// The response and the timeout race against each other; whichever
// one fires first wins. A naive `clearTimeout` is not enough — once
// the timer has already been queued for the next tick, clearTimeout
// is a no-op and the timeout callback runs even after the response
// arrived, which would commit the quit even on a `hasDirty=true`
// reply (i.e. silently override the user's "save first" intent).
let settled = false;
let timeoutId = null;
const settle = (decision) => {
if (settled) return;
settled = true;
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
_ipcMain.removeListener("app:dirty-editors-result", onResult);
quitGuardChannelBusy = false;
if (decision === "commit") commitQuit();
// decision === "stay": renderer showed a toast for dirty editors.
// Do not touch isQuitting so tray / close-to-tray gating keeps working.
};
function onResult(evt, payload) {
// Defence in depth: this channel is queried with a specific
// webContents in mind. A reply from any other window (e.g. a
// misbehaving extension or a future bug that wires the channel
// elsewhere) is silently ignored so it can't decide the quit.
// We use `.on` (not `.once`) so a rogue reply doesn't consume
// the listener slot and let the real reply fall through. Reject
// strictly: a missing/falsy sender is anomalous in real IPC and
// is treated the same as a wrong-window reply.
if (evt?.sender !== wc) return;
const hasDirty = payload && payload.hasDirty === true;
settle(hasDirty ? "stay" : "commit");
}
_ipcMain.on("app:dirty-editors-result", onResult);
// Timeout fallback: if the renderer never replies (crash, unhandled
// exception in the listener, etc.) we'd otherwise be stuck with
// quitGuardChannelBusy=true and the app un-quittable.
const timeoutId = setTimeout(() => {
_ipcMain.removeAllListeners("app:dirty-editors-result");
quitGuardChannelBusy = false;
commitQuit();
}, QUIT_GUARD_TIMEOUT_MS);
timeoutId = setTimeout(() => settle("commit"), QUIT_GUARD_TIMEOUT_MS);
_ipcMain.once("app:dirty-editors-result", (_evt, { hasDirty }) => {
clearTimeout(timeoutId);
quitGuardChannelBusy = false;
if (!hasDirty) {
commitQuit();
}
// If hasDirty === true the renderer has shown a toast; stay put. Do not
// touch isQuitting so tray/close-to-tray gating keeps working.
});
try {
wc.send("app:query-dirty-editors");
} catch (err) {
// `webContents.send` can throw if the renderer was destroyed
// between our `isCrashed?.()` check and this call (a real race
// when the GPU process is dying). Tear the listener/timer down
// synchronously so we don't strand quitGuardChannelBusy=true.
console.warn("[Main] Failed to query renderer for dirty editors:", err);
settle("commit");
}
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting

View File

@@ -548,6 +548,12 @@ const api = {
const result = await ipcRenderer.invoke("netcatty:mosh:start", options);
return result.sessionId;
},
detectMoshClient: async () => {
return ipcRenderer.invoke("netcatty:mosh:detectClient");
},
pickMoshClient: async () => {
return ipcRenderer.invoke("netcatty:mosh:pickClient");
},
startLocalSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
return result.sessionId;

12
global.d.ts vendored
View File

@@ -73,6 +73,8 @@ declare global {
keyId?: string;
keySource?: 'generated' | 'imported';
agentForwarding?: boolean;
x11Forwarding?: boolean;
x11Display?: string;
cols?: number;
rows?: number;
charset?: string;
@@ -177,6 +179,7 @@ declare global {
username?: string;
port?: number;
moshServerPath?: string;
moshClientPath?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
@@ -184,6 +187,13 @@ declare global {
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
detectMoshClient?(): Promise<{
platform: string;
found: boolean;
path: string | null;
searchedPaths: string[];
}>;
pickMoshClient?(): Promise<{ canceled: boolean; filePath: string | null }>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
@@ -206,7 +216,7 @@ declare global {
}>>;
getDefaultShell?(): Promise<string>;
discoverShells?(): Promise<DiscoveredShell[]>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;

View File

@@ -1,5 +1,25 @@
@import "tailwindcss";
/* Bundled icon-only fallback so terminals show Nerd Font glyphs (powerline,
devicons, etc.) regardless of which base font the user picks. The font is
referenced last in the fontFamily fallback chain (see withCjkFallback in
infrastructure/config/fonts.ts) — base text comes from the user's chosen
font, missing PUA glyphs fall through to this face.
Source: https://github.com/ryanoasis/nerd-fonts (NerdFontsSymbolsOnly,
v3.4.0). License: MIT — see public/fonts/SymbolsNerdFont-LICENSE.txt. */
@font-face {
font-family: "Symbols Nerd Font Mono";
/* Absolute path resolves against the document origin in both dev
(http://localhost:5173) and packaged (app://netcatty), regardless of
where the bundled CSS file ends up. A relative ./fonts/... would be
resolved against dist/assets/index-*.css in production and 404. */
src: url("/fonts/SymbolsNerdFontMono-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: block;
}
/* ============================================
Tailwind CSS v4 Theme Configuration
============================================ */
@@ -398,6 +418,11 @@ body {
.workspace-pane:not(:focus-within) .xterm-screen {
opacity: 0.82;
}
/* Keep current pane fully visible while its context menu / popover is open;
focus moves to the menu portal and would otherwise drop :focus-within. */
.workspace-pane[data-menu-open] .xterm-screen {
opacity: 1;
}
/* Border-style focus indicator (opt-in via data attribute) */
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {
opacity: 1;

View File

@@ -28,16 +28,37 @@ const CJK_FALLBACK_FONTS = [
'"SimSun"',
];
// Nerd Font symbol-only fallback. Appended after CJK fallbacks so the browser
// can locate Private Use Area glyphs (powerline / devicons / etc.) when the
// primary font does not ship them — without forcing the user to pick a Nerd
// Font variant manually. Mono variants come first to preserve cell width.
const NERD_FONT_FALLBACK_FONTS = [
'"Symbols Nerd Font Mono"',
'"Symbols Nerd Font"',
];
const CJK_FALLBACK_STACK = CJK_FALLBACK_FONTS.join(', ');
const NERD_FONT_FALLBACK_STACK = NERD_FONT_FALLBACK_FONTS.join(', ');
export const withCjkFallback = (family: string) => {
const trimmed = family.trim();
if (!CJK_FALLBACK_STACK) return trimmed;
// Avoid double-appending if a custom stack already includes one of these fonts.
if (CJK_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, "")))) {
return trimmed;
const segments: string[] = [trimmed];
if (
CJK_FALLBACK_STACK &&
!CJK_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
) {
segments.push(CJK_FALLBACK_STACK);
}
return `${trimmed}, ${CJK_FALLBACK_STACK}`;
if (
NERD_FONT_FALLBACK_STACK &&
!NERD_FONT_FALLBACK_FONTS.some((f) => trimmed.includes(f.replace(/"/g, '')))
) {
segments.push(NERD_FONT_FALLBACK_STACK);
}
return segments.join(', ');
};
const BASE_TERMINAL_FONTS: TerminalFont[] = [

View File

@@ -1050,6 +1050,19 @@ export class CloudSyncManager {
this.updateProviderStatus(provider, 'error', error);
}
/**
* Release the transient 'connecting' UI state without disturbing the adapter
* or the auth restore snapshot. Used by PKCE flows after the browser handoff
* has succeeded, so the settings page isn't visually stuck at "connecting"
* while we wait for the redirect callback in the background.
*/
clearConnectingStatus(provider: CloudProvider): void {
if (this.state.providers[provider]?.status !== 'connecting') {
return;
}
this.updateProviderStatus(provider, 'disconnected');
}
clearProviderError(provider: CloudProvider): void {
const connection = this.state.providers[provider];
if (!connection?.error && connection?.status !== 'error') {

View File

@@ -55,6 +55,8 @@ const KNOWN_MONOSPACE_FONTS = new Set([
'sarasa mono',
'maple mono',
'meslolgs nf',
'symbols nerd font mono',
'symbols nerd font',
]);
/**

View File

@@ -30,7 +30,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.