Compare commits

...

42 Commits

Author SHA1 Message Date
bincxz
1f0d3d8274 Handle cross-device mosh bundle moves
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-05-01 17:10:13 +08:00
bincxz
d8c62a55f5 Fix Windows mosh bundle extraction 2026-05-01 16:54:57 +08:00
陈大猫
1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
bincxz
de7057183c Increase AI code block top spacing 2026-05-01 13:48:42 +08:00
bincxz
dd910cc53d Tighten AI code block spacing 2026-05-01 13:43:06 +08:00
陈大猫
8ccefc821c [codex] Use dedicated mosh binary repository (#881)
* Use dedicated mosh binary repository

* Require bundled mosh client

* Auto-fill saved password for mosh SSH handshake

* Harden bundled mosh binary flow
2026-05-01 11:54:10 +08:00
陈大猫
863397fc7d Fix DeepSeek reasoning replay for tool loops (#882)
* Fix OpenAI-compatible reasoning replay for tool loops

* Fix reasoning continuation replay
2026-05-01 11:45:47 +08:00
陈大猫
6a39ed05a9 [codex] Tighten AI chat spacing (#883)
* Tighten AI chat spacing

* Scope AI table spacing styles
2026-05-01 11:33:07 +08:00
陈大猫
470d9b5aae [codex] Improve ACP agent error diagnostics (#880) 2026-05-01 08:00:50 +08:00
陈大猫
20694a47dd Fix Codex ACP model picker (#879) 2026-05-01 08:00:05 +08:00
陈大猫
d86c5ed05a [codex] Remove mosh client path setting (#878)
* fix(terminal): remove mosh client path setting

* fix(terminal): remove stale mosh detection bridge
2026-04-30 17:54:35 +08:00
陈大猫
fdaaaf62d8 [codex] Preserve provider reasoning context (#877)
* fix(ai): preserve provider reasoning context

* fix(ai): harden provider continuation replay
2026-04-30 17:08:19 +08:00
秋秋
2ceea46b50 feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval (#869)
* feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval

* fix ssh cwd detection review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 15:27:45 +08:00
Eric Chan
5a1d6931a5 Fix Tab completion preferring history over local files (#867)
* Fix spec-aware path completion priority

Use resolved Fig spec args when deciding when filesystem suggestions should outrank command history. Add a regression test covering a spec-driven file argument command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix generator-only spec path completion

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:42:01 +08:00
yuzifu
fb97e242ee feat: add SFTP upload conflict handling (#874)
* feat: add SFTP upload conflict handling
Add conflict resolution for SFTP uploads so files and folders can be stopped, skipped, replaced, duplicated, or merged depending on the target state. Support batch uploads with Apply to All behavior, route external upload conflicts through the shared SFTP conflict dialog, and add the bridge operations needed to stat and delete existing upload targets.

* fix review issue

* Fix SFTP conflict cancellation cleanup

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:22:00 +08:00
YumeSaku
68040ebdd7 fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators (#871)
* fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators

oh-my-posh and similar themed prompts end with PUA codepoints (e.g. U+F105
chevron, U+E0B0 powerline arrow) that aren't in the hardcoded PROMPT_CHARS
set, so findPromptBoundary returned -1 and both ghost-text and popup
autocomplete went silent. Treat any Private Use Area char (U+E000-U+F8FF)
followed by a space as a candidate prompt terminator — real shell commands
essentially never contain PUA codepoints, so this is high-confidence.

* Fix Powerline glyph prompt splitting

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:57:07 +08:00
Blossom
cca6dac543 fix(sftp): use custom tooltips in transfer queue (#872)
* fix(sftp): replace transfer queue native tooltips

* Fix SFTP transfer tooltip regressions

* Improve SFTP transfer tooltip accessibility

* Cover SFTP cancel tooltip label

---------

Co-authored-by: Mack Ding <mackding@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:23:51 +08:00
陈大猫
d86b720748 Run CI on every push/PR; gate release on strict v tags (#868)
* Run CI on every push/PR; gate release on strict v<X>.<Y>.<Z> tags

The build-packages workflow used to trigger only on `push: tags: v*`,
so branches and PRs never built and the only way to test the matrix
was to push a tag — which also auto-published a GitHub Release. That
made it impossible to verify a CI change without either skipping
testing or shipping a junk release.

Restructure the triggers:

- `push: branches: ['**']` + `pull_request` so any push or PR runs
  the build matrix and uploads workflow artifacts.
- `push: tags` accepts only strict semver: `v<MAJOR>.<MINOR>.<PATCH>`
  with an optional pre-release suffix like `v1.2.3-rc.1`. Loose tags
  (`v-test`, `vNEXT`, `v1.0`) no longer match.
- The release job's `if:` enforces the same rule independently — even
  if someone re-broadens the trigger later, branches and PRs can't
  publish a release.
- `Set version` produces semver-compliant `0.0.0-sha.<short>` for
  non-tag runs so `npm pkg set` / electron-builder don't choke on a
  bare commit SHA like `abc1234`.
- Add a concurrency group that cancels superseded branch/PR builds
  to save runner minutes; tag builds use a unique group so releases
  never get cancelled by a follow-up commit.

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

* Apply strict-semver Set-version step to Linux jobs too

The previous commit only patched the matrix job's Set version step
(macOS/Windows) because the Linux legs had a slightly different
template (no comments). The Linux Set version step kept setting
package.json's version to a bare 7-char commit SHA like "812f296",
which electron-builder rejects with `Invalid version: "812f296"`
during normalizePackageData.

Replicate the same strict regex + 0.0.0-sha.<short> fallback in both
Linux jobs so non-tag runs produce a valid semver across the matrix.

Reproduced from build-linux-x64 logs of the run on 112bf3a1:
  Setting version to 812f296
  ⨯ Invalid version: "812f296"  failedTask=build

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

* Fix build workflow trigger review issues

* Address build workflow review findings

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:22:50 +08:00
陈大猫
aa192c66c3 Wire bundled mosh release flow
* Wire bundled mosh release flow

* Fix bundled mosh release flow review findings
2026-04-30 09:28:08 +08:00
陈大猫
7dd25a55bb Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline

Add a GitHub Actions workflow that builds a static, distro-portable
mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64)
from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary
sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so
scripts/fetch-mosh-binaries.cjs can verify and pull the right binary
into resources/mosh/<platform-arch>/ during npm run pack.

electron-builder.config.cjs gains a moshExtraResources() helper that
adds the binary to extraResources only when present on disk, keeping
local dev packages working without bundled mosh.

terminalBridge.cjs now exports bundledMoshClient() and prefers the
bundled static client over whatever the system mosh wrapper would
resolve via PATH (via the MOSH_CLIENT env var). The Windows branch
throws a clear error pointing at Settings instead of silently falling
back to a literal "mosh.exe" string when no wrapper is installed.

This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal
Windows binary with an in-CI Cygwin static build and adds a Node-side
mosh-server bootstrap so Mosh works out-of-the-box on Windows.

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

* Phase 2: Node-side Mosh handshake (no Perl wrapper required)

Reimplement what the upstream Mosh Perl wrapper does in pure Node:
spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream
for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally
with MOSH_KEY in the environment.

The new electron/bridges/moshHandshake.cjs module exposes the parser,
sniffer, and command builders as pure functions so they can be unit
tested without spawning real ssh. terminalBridge.startMoshSession now
prefers this path whenever a bare mosh-client (bundled, explicit, or
system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere
else) are both detectable. The legacy path through the system mosh
Perl wrapper is preserved as a fallback so users with custom mosh
setups don't regress.

Auth is delegated to system ssh, so keys, agent, ssh_config, and
known_hosts all keep working. Password / 2FA need a controlling TTY
which the bootstrap doesn't provide; affected users keep the legacy
wrapper path until interactive UI lands.

Tests:
- moshHandshake.test.cjs (20 tests) — parser corner cases, command
  builders, sniffer split-chunk handling, ring-buffer trim, exec
  resolver
- terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path
  basename gating

317 → 341 passing tests; lint clean.

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

* Phase 3: in-CI Cygwin Windows build + visible PTY handshake

Phase 3a — in-CI Cygwin Windows build
- scripts/build-mosh/build-windows.sh builds mosh-client.exe from
  upstream mobile-shell/mosh source inside Cygwin, then walks the
  cygcheck import graph to bundle every required Cygwin DLL
  (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a
  tar.gz alongside the exe.
- The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned
  fetch job for a real Cygwin build (windows-latest + cygwin-install-
  action). fetch-windows.sh is preserved as an emergency fallback but
  no longer wired into the matrix.
- fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/
  win32-x64/ so mosh-client.exe sits next to its DLLs.
- mosh-extra-resources.cjs ships the entire win32-x64/ dir
  (exe + DLL bundle) into Resources/mosh/, so the packaged installer
  runs on a stock Windows host with no Cygwin install.

Phase 3b — visible PTY handshake (password / 2FA prompts)
- terminalBridge.startMoshSession now spawns ssh inside node-pty so
  the user sees and can answer password / 2FA / known-hosts prompts
  in their terminal. When `MOSH CONNECT` is sniffed from the byte
  stream, session.proc is atomically swapped from the ssh PTY to a
  freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is
  redacted from the visible output.
- writeToSession / resizeSession read session.proc lazily, so input
  arriving after the swap goes to mosh-client without extra wiring.
- The ZMODEM sentry is recreated for the new proc since its
  writeToRemote closure captured the previous handle.
- Removes the earlier non-PTY child_process.spawn handshake — the
  PTY-based one supersedes it.

Phase 3c — win32-arm64 deferred
- Cygwin's arm64 port has no stable cygwin1.dll release yet, so we
  do not attempt an arm64 Windows build. arm64 Windows installs fall
  through to the legacy `mosh` wrapper path that the bridge already
  handles. Documented in the workflow.

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

* Allow branch/PR pushes to test the mosh-binaries workflow

Mirrors the build-packages workflow change in #868: any push or PR
that touches the mosh build pipeline triggers the matrix (artifacts
only, no release), while only `mosh-bin-*` tag pushes (or an
explicit workflow_dispatch with release_tag) publish a release.

`paths` filter keeps unrelated commits from running this expensive
workflow (~30min for the Cygwin leg). Concurrency group cancels
superseded branch/PR builds; tag builds use a unique group so a
follow-up commit can't kill an in-progress release.

Release job's `if:` enforces the same rule independently — even if
the trigger gets re-broadened, branches/PRs can't leak a release.

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

* Fix mosh binary workflow runners

* Fix Windows mosh workflow invocation

* Keep shell scripts LF in workflow checkouts

* Trigger mosh workflow on attributes changes

* Fix mosh build tool dependencies

* Fix Linux mosh static build

* Fix macOS mosh build tool lookup

* Skip macOS ncurses terminfo install

* Fix mosh PR review findings

* Allow Linux system mosh dependencies

* Fix Windows mosh DLL bundling

* Limit bundled Windows mosh DLLs

* Honor configured PATH for mosh handshake

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:25:57 +08:00
陈大猫
e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫
4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
陈大猫
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
140 changed files with 12866 additions and 1031 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -56,8 +56,7 @@ const files = {
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
x64: `Netcatty-${version}-win-x64.exe`
},
linux: {
appimage: {
@@ -77,8 +76,7 @@ const files = {
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
@@ -99,7 +97,7 @@ const content = `
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;

View File

@@ -0,0 +1,262 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
# source. Cygwin's POSIX runtime can't be fully statically linked, so
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
# path is preserved as `fetch-windows.sh` for emergency fallback.
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Cygwin
uses: cygwin/cygwin-install-action@v5
with:
add-to-path: false
# Keep package signature checks, but avoid the setup.exe hash
# fetch path that currently fails on windows-latest runners.
check-hash: false
packages: >
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
libncursesw-devel zlib-devel protobuf-compiler
- name: Build mosh-client.exe (win32-x64)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$cygwinBin = "C:\cygwin\bin"
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
$script = @'
set -euo pipefail
cd "__WORKSPACE__"
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
export ARCH=x64
export OUT_DIR="__WORKSPACE__/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/build-windows.sh
'@
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
# release for aarch64 as of this commit), so we don't attempt an
# arm64 mosh build. arm64 Windows installs fall through to the
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
# When upstream Cygwin ships a stable arm64 build, drop the same
# cygwin-install-action job below with `platform: arm64`.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi

View File

@@ -1,5 +1,23 @@
name: build-packages
# Trigger philosophy
# - Any push to any branch + any PR -> run the build matrix so CI is
# always testable. Same-repo PR runs own package validation; matching
# branch push runs become a lightweight mirror only after a current
# open PR run for the same commit is visible. If lookup is slow or
# unavailable, the push run falls back to the full matrix. Artifacts
# upload as workflow artifacts only; *no* GitHub Release is published.
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
# `v1.0` no longer auto-publish.
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
# `publish_release` only publishes when the selected ref is also a
# strict version tag.
#
# The release job validates the exact same rule before publishing, so
# adding branches/PRs above is safe; accidental tag-like branch names
# won't leak a release.
on:
workflow_dispatch:
inputs:
@@ -7,13 +25,179 @@ on:
description: "Publish GitHub Release after build"
type: boolean
default: false
mosh_bin_release:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
push:
branches:
- "**"
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
pull_request:
# A newer run for the same push branch or PR cancels older in-progress
# work. Push and PR events stay in separate groups so deduped push runs
# can mirror PR results cleanly instead of leaving cancelled checks on
# the PR. Publishing tag runs share a release group across push and
# manual dispatch; non-publishing manual tag runs use their own group.
concurrency:
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
actions: read
contents: read
pull-requests: read
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
dedupe:
name: dedupe push run
runs-on: ubuntu-latest
outputs:
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
steps:
- name: Detect duplicate heavy CI
id: detect
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.ref_name }}
HEAD_SHA: ${{ github.sha }}
run: |
skip_heavy_ci=false
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
pr_count=0
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
-f state=open \
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
-F per_page=1 \
--jq 'length')"; then
echo "::warning::Could not check open PRs; running full push CI."
pr_count=0
fi
pr_run_id=""
if [[ "$pr_count" != "0" ]]; then
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
for attempt in {1..18}; do
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=pull_request \
-f "branch=${HEAD_REF}" \
-f "head_sha=${HEAD_SHA}" \
-F per_page=20 \
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
echo "::warning::Could not check PR workflow runs; running full push CI."
pr_run_id=""
break
fi
if [[ -n "$pr_run_id" ]]; then
skip_heavy_ci=true
break
fi
if [[ "$attempt" == "18" ]]; then
break
fi
sleep 10
done
fi
if [[ -n "$pr_run_id" ]]; then
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
echo "heavy_ci_pr_run_id=${pr_run_id}"
fi
fi
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
echo "skip_heavy_ci=${skip_heavy_ci}"
dedupe-result:
name: dedupe result
needs: dedupe
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
runs-on: ubuntu-latest
steps:
- name: Mirror PR build result
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
run: |
if [[ -z "$PR_RUN_ID" ]]; then
echo "::error::No PR workflow run was selected for dedupe."
exit 1
fi
for attempt in {1..360}; do
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
sleep 30
continue
fi
status="${result%%|*}"
conclusion="${result#*|}"
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
exit 0
fi
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
exit 1
fi
sleep 30
done
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
exit 1
resolve-mosh:
name: resolve bundled mosh-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled mosh-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-mosh-bin-release.cjs
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::MOSH_BIN_RELEASE was not resolved."
exit 1
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: build-${{ matrix.name }}
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,13 +208,28 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -46,27 +245,40 @@ jobs:
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# npm ci only installs optional deps for the host platform.
# macOS packages still cover both arm64 and x64, so we need
# codex-acp for both architectures there.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
fi
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:mosh -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
# tags / branches / PRs fall through to a semver-pre-release
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
# accept it. Non-semver versions (e.g. bare "abc1234") cause
# downstream tooling to error or pick weird codepaths.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -105,9 +317,15 @@ jobs:
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -115,6 +333,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -130,10 +359,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -143,6 +375,10 @@ jobs:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Build package
env:
npm_config_arch: x64
@@ -171,11 +407,17 @@ jobs:
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -183,6 +425,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
@@ -201,10 +454,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -214,6 +470,10 @@ jobs:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64
@@ -242,7 +502,12 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
# Manual workflow_dispatch can publish only when it is run from one
# of those tags. PRs and branch pushes skip this job.
if: |
startsWith(github.ref, 'refs/tags/v')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
@@ -250,6 +515,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
exit 1
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -318,6 +591,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/*.dmg
artifacts/*.zip

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: test
on:
pull_request:
push:
branches:
- "**"
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test

10
.gitignore vendored
View File

@@ -63,3 +63,13 @@ Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries (and on Windows the Cygwin DLL
# bundle that ships alongside mosh-client.exe) are pulled from the
# dedicated mosh binary repository, never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll

90
App.tsx
View File

@@ -17,13 +17,14 @@ import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, 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';
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -57,7 +58,7 @@ import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
import { TextEditorTabView } from './components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
import { editorSftpWrite } from './application/state/editorSftpBridge';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from './application/state/editorTabSave';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -206,6 +207,8 @@ function App({ settings }: { settings: SettingsState }) {
theme,
setTheme,
resolvedTheme,
accentMode,
customAccent,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
@@ -365,14 +368,19 @@ function App({ settings }: { settings: SettingsState }) {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
// Workspace
@@ -402,7 +410,7 @@ function App({ settings }: { settings: SettingsState }) {
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
@@ -440,7 +448,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildSyncPayload(
return buildLocalVaultPayload(
{
hosts,
keys,
@@ -556,7 +564,6 @@ function App({ settings }: { settings: SettingsState }) {
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
startupReady: startupSyncSafetyReady,
@@ -880,9 +887,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 +1049,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 +1311,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
@@ -1730,6 +1769,7 @@ function App({ settings }: { settings: SettingsState }) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
@@ -1752,16 +1792,15 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
if (choice === 'save') {
try {
editorTabStore.setSavingState(id, 'saving');
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
closeEditorAndActivateNeighbor(id);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Save failed';
editorTabStore.setSavingState(id, 'error', msg);
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return;
closeEditorAndActivateNeighbor(id);
}
};
@@ -1882,6 +1921,8 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
@@ -1926,6 +1967,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,9 @@ 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.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).',
@@ -775,6 +778,9 @@ const en: Messages = {
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
@@ -841,8 +847,11 @@ const en: Messages = {
'sftp.conflict.size': 'Size:',
'sftp.conflict.modified': 'Modified:',
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
'sftp.conflict.action.stop': 'Stop',
'sftp.conflict.action.skip': 'Skip',
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.duplicate': 'Duplicate',
'sftp.conflict.action.merge': 'Merge',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
@@ -1077,6 +1086,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

@@ -562,6 +562,9 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
@@ -712,6 +715,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。',
@@ -1211,8 +1217,11 @@ const zhCN: Messages = {
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改时间:',
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳过',
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.duplicate': '创建副本',
'sftp.conflict.action.merge': '合并',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
@@ -1456,6 +1465,9 @@ 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.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
import { createEditorTabSaveService } from "./editorTabSave.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/tmp/file.txt",
fileName: "file.txt",
languageId: "plaintext",
content: "v1",
baselineContent: "old",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("editor tab save service joins duplicate saves for the same content", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const pending = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await pending.promise;
},
});
const first = service.saveTab("edt_1");
const second = service.saveTab("edt_1", "v1");
assert.deepEqual(writes, ["v1"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(writes, ["v1"]);
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
assert.equal(store.getTab("edt_1")?.savingState, "idle");
});
test("editor tab save service queues newer tab content after an in-flight save", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const firstSave = deferred();
const secondSave = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = service.saveTab("edt_1");
store.updateContent("edt_1", "v2", null);
const second = service.saveTab("edt_1");
assert.deepEqual(writes, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(writes, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
assert.equal(store.getTab("edt_1")?.content, "v2");
});

View File

@@ -0,0 +1,72 @@
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from "./textEditorSaveCoordinator";
interface EditorTabSaveServiceDeps {
store: EditorTabStore;
write: EditorSftpWrite;
}
export interface EditorTabSaveService {
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
releaseTab(id: EditorTabId): void;
}
const formatSaveError = (error: unknown): string =>
error instanceof Error ? error.message : "Save failed";
export const createEditorTabSaveService = ({
store,
write,
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
const existing = coordinators.get(id);
if (existing) return existing;
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
const tab = store.getTab(id);
if (!tab) throw new Error("Editor tab closed before save completed");
await write(tab.sessionId, tab.hostId, tab.remotePath, content);
},
onSaveStart: () => {
store.setSavingState(id, "saving");
},
onSaveSuccess: (content) => {
store.markSaved(id, content);
},
onSaveError: (error) => {
store.setSavingState(id, "error", formatSaveError(error));
},
});
coordinators.set(id, coordinator);
return coordinator;
};
return {
saveTab: async (id, contentOverride) => {
const tab = store.getTab(id);
if (!tab) return false;
return getCoordinator(id).save(contentOverride ?? tab.content);
},
releaseTab: (id) => {
const coordinator = coordinators.get(id);
coordinator?.reset();
coordinators.delete(id);
},
};
};
const editorTabSaveService = createEditorTabSaveService({
store: editorTabStore,
write: editorSftpWrite,
});
export const saveEditorTab = editorTabSaveService.saveTab;
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;

View File

@@ -196,3 +196,24 @@ test("confirmCloseBySession invokes save callback for 'save' choice and only clo
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});

View File

@@ -167,17 +167,23 @@ export class EditorTabStore {
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") { this.close(tab.id); continue; }
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
@@ -186,6 +192,7 @@ export class EditorTabStore {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}

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

@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import React, { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -63,6 +63,8 @@ interface SftpExternalOperationsResult {
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
uploadConflicts: FileConflict[];
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
}
export const useSftpExternalOperations = (
@@ -88,6 +90,11 @@ export const useSftpExternalOperations = (
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef(new Map<string, {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
}>());
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
@@ -496,18 +503,99 @@ export const useSftpExternalOperations = (
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
const resolver = uploadConflictResolversRef.current.get(conflictId);
if (!resolver) return;
uploadConflictResolversRef.current.delete(conflictId);
if (conflict && applyToAll) {
resolver.setDefault(action);
}
resolver.resolve(action);
}, [uploadConflicts]);
const cancelPendingUploadConflicts = useCallback(() => {
const resolvers = Array.from(uploadConflictResolversRef.current.values());
if (resolvers.length === 0) return;
uploadConflictResolversRef.current.clear();
setUploadConflicts([]);
for (const resolver of resolvers) {
resolver.resolve("stop");
}
}, []);
const createUploadConflictResolver = useCallback(() => {
const conflictDefaults = new Map<string, FileConflictAction>();
return async (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
const fileConflict: FileConflict = {
transferId: conflictId,
fileName: conflict.fileName,
sourcePath: "local",
targetPath: conflict.targetPath,
isDirectory: conflict.isDirectory,
existingType: conflict.existingType,
applyToAllCount: conflict.applyToAllCount,
existingSize: conflict.existingSize,
newSize: conflict.newSize,
existingModified: conflict.existingModified,
newModified: conflict.newModified,
};
setUploadConflicts((prev) => [...prev, fileConflict]);
return new Promise<FileConflictAction>((resolve) => {
uploadConflictResolversRef.current.set(conflictId, {
resolve,
setDefault: (action) => {
conflictDefaults.set(conflictType, action);
},
});
});
};
}, []);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
deleteLocalFile: bridge?.deleteLocalFile,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
statSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
@@ -596,6 +684,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller
);
@@ -624,6 +713,7 @@ export const useSftpExternalOperations = (
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
@@ -680,6 +770,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
@@ -707,6 +798,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
@@ -716,11 +808,14 @@ export const useSftpExternalOperations = (
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
let cancelPromise: Promise<void> | undefined;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
cancelPromise = controller.cancel();
}
}, []);
cancelPendingUploadConflicts();
await cancelPromise;
}, [cancelPendingUploadConflicts]);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -744,5 +839,7 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
};
};

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
FileConflictAction,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
@@ -61,7 +62,7 @@ interface UseSftpTransfersResult {
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
}
interface TransferResult {
@@ -96,6 +97,7 @@ export const useSftpTransfers = ({
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
const conflictDefaultsRef = useRef<Map<string, FileConflictAction>>(new Map());
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
@@ -122,6 +124,196 @@ export const useSftpTransfers = ({
[],
);
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
[],
);
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: fileName, ext: "" };
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return { baseName: fileName, ext: "" };
return {
baseName: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}, []);
const statTargetPath = useCallback(
async (
targetPane: SftpPane,
targetSftpId: string | null,
targetPath: string,
targetEncoding: SftpFilenameEncoding,
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
},
[splitNameForDuplicate, statTargetPath],
);
const completeCancelledTask = useCallback(
async (task: TransferTask) => {
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
},
[],
);
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
const idsToCancel = new Set<string>();
const currentTransfers = transfersRef.current;
for (const transferId of transferIds) {
idsToCancel.add(transferId);
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.add(childId);
cancelledTasksRef.current.add(childId);
}
}
for (const transfer of currentTransfers) {
if (
transfer.parentTaskId === transferId &&
(transfer.status === "transferring" || transfer.status === "pending")
) {
idsToCancel.add(transfer.id);
cancelledTasksRef.current.add(transfer.id);
}
}
}
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
if (!cancelTransferAtBackend) return;
await Promise.all(
Array.from(idsToCancel).map((id) =>
cancelTransferAtBackend(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}, []);
const markBatchStopped = useCallback(
async (task: TransferTask) => {
const batchId = task.batchId;
const affected = transfersRef.current.filter((candidate) =>
candidate.id === task.id ||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
);
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
const affectedIds = new Set(affected.map((candidate) => candidate.id));
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
setTransfers((prev) => {
for (const candidate of prev) {
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
cancelledTasksRef.current.add(candidate.id);
}
}
return prev
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
.map((candidate) =>
affectedIds.has(candidate.id)
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
: candidate,
);
});
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
for (const candidate of affected) {
await completeCancelledTask(candidate);
}
},
[cancelBackendTransfers, completeCancelledTask],
);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
},
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
@@ -557,6 +749,10 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
}
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
@@ -676,7 +872,7 @@ export const useSftpTransfers = ({
// Run size discovery and conflict check in parallel
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
if (task.skipConflictCheck || task.isDirectory || !targetPane.connection) return null;
if (task.skipConflictCheck || !targetPane.connection) return null;
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
@@ -684,30 +880,26 @@ export const useSftpTransfers = ({
: null;
try {
let existingStat: { size: number; mtime: number } | null = null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
}
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
return {
transferId: task.id,
batchId: task.batchId,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -729,6 +921,44 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
return "cancelled";
}
if (defaultAction === "skip") {
cancelledTasksRef.current.add(task.id);
updateTask({ status: "cancelled", endTime: Date.now() });
await completeCancelledTask(task);
return "cancelled";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
const updatedTask: TransferTask = {
...task,
...(duplicateTarget
? {
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
}
: null),
skipConflictCheck: true,
replaceExistingTarget: defaultAction === "replace",
};
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}
setConflicts((prev) => [...prev, conflict]);
updateTask({
status: "pending",
@@ -741,6 +971,10 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.replaceExistingTarget) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding);
}
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
@@ -816,6 +1050,10 @@ export const useSftpTransfers = ({
);
}
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const finalStatus: TransferStatus = dirPartialFailure ? "failed" : "completed";
setTransfers((prev) => {
return prev.map((t) => {
@@ -940,6 +1178,7 @@ export const useSftpTransfers = ({
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const newTasks: TransferTask[] = [];
@@ -965,6 +1204,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: crypto.randomUUID(),
batchId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
@@ -1032,37 +1272,10 @@ export const useSftpTransfers = ({
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
// Cancel parent and all active child streams at the backend.
// Use activeChildIdsRef for immediate visibility (not subject to
// React state batching delays like transfersRef).
const idsToCancel = [transferId];
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.push(childId);
cancelledTasksRef.current.add(childId);
}
}
// Also check rendered state as fallback for transfers started
// via other paths (e.g. startTransfer/processTransfer)
const currentTransfers = transfersRef.current;
for (const t of currentTransfers) {
if (t.parentTaskId === transferId && (t.status === "transferring" || t.status === "pending") && !idsToCancel.includes(t.id)) {
idsToCancel.push(t.id);
}
}
await Promise.all(
idsToCancel.map((id) =>
netcattyBridge.get()!.cancelTransfer!(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}
await cancelBackendTransfers([transferId]);
},
[],
[cancelBackendTransfers],
);
const retryTransfer = useCallback(
@@ -1155,79 +1368,123 @@ export const useSftpTransfers = ({
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) return;
if (!task) {
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
}
setConflicts((prev) => prev.filter((c) => !affectedConflictIds.has(c.transferId)));
if (affectedTasks.length === 0) {
return;
}
if (action === "stop") {
await markBatchStopped(task);
return;
}
if (action === "skip") {
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
for (const affectedTask of affectedTasks) {
await completeCancelledTask(affectedTask);
}
return;
}
let updatedTask = { ...task };
const updatedTasks: TransferTask[] = [];
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = joinPath(getParentPath(task.targetPath), newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints) continue;
const targetSftpId = endpoints.targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
const targetEncoding = endpoints.targetPane.connection?.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
updatedTask = {
...affectedTask,
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: true,
};
} else if (action === "merge") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: false,
};
}
updatedTasks.push(updatedTask);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
prev.map((t) => {
const updatedTask = updatedTaskMap.get(t.id);
return updatedTask
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
: t;
}),
);
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
for (const updatedTask of updatedTasks) {
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
[resolveTaskEndpoints],
[
completeCancelledTask,
conflictDefaultKey,
getDuplicateTarget,
markBatchStopped,
resolveTaskEndpoints,
sftpSessionsRef,
],
);
const activeTransfersCount = useMemo(() => transfers.filter(

View File

@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorSaveCoordinator } from "./textEditorSaveCoordinator.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
test("text editor save coordinator joins duplicate saves already in flight", async () => {
const pending = deferred();
const saved: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await pending.promise;
},
onSavingChange: (saving) => savingStates.push(saving),
});
const first = coordinator.save("remote text");
const second = coordinator.save("remote text");
assert.deepEqual(saved, ["remote text"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(saved, ["remote text"]);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator saves newer content after an in-flight save finishes", async () => {
const firstSave = deferred();
const secondSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = coordinator.save("v1");
const second = coordinator.save("v2");
assert.deepEqual(saved, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(saved, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("text editor save coordinator returns false to duplicate callers when the in-flight save fails", async () => {
const pending = deferred();
const errors: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
throw new Error("denied");
},
onSaveError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
const first = coordinator.save("content");
const second = coordinator.save("content");
pending.resolve();
assert.equal(await first, false);
assert.equal(await second, false);
assert.deepEqual(errors, ["denied"]);
});
test("text editor save coordinator reset prevents an old in-flight save from updating the next file", async () => {
const pending = deferred();
const successes: string[] = [];
const errors: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
},
onSaveSuccess: (content) => successes.push(content),
onSaveError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
onSavingChange: (saving) => savingStates.push(saving),
});
const save = coordinator.save("old file");
coordinator.reset();
pending.resolve();
assert.equal(await save, false);
assert.deepEqual(successes, []);
assert.deepEqual(errors, []);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator reset cancels queued stale saves", async () => {
const firstSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await firstSave.promise;
},
});
const first = coordinator.save("old v1");
const queued = coordinator.save("old v2");
coordinator.reset();
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(await first, false);
assert.equal(await queued, false);
assert.deepEqual(saved, ["old v1"]);
});

View File

@@ -0,0 +1,90 @@
export interface TextEditorSaveCoordinator {
save(content: string): Promise<boolean>;
isSaving(): boolean;
reset(): void;
}
export interface TextEditorSaveCoordinatorOptions {
onSave: (content: string) => Promise<void>;
onSaveStart?: (content: string) => void;
onSaveSuccess?: (content: string) => void;
onSaveError?: (error: unknown) => void;
onSavingChange?: (saving: boolean) => void;
}
interface InFlightSave {
content: string;
promise: Promise<boolean>;
}
export const createTextEditorSaveCoordinator = (
options: TextEditorSaveCoordinatorOptions,
): TextEditorSaveCoordinator => {
let inFlight: InFlightSave | null = null;
let generation = 0;
const notifySavingChange = () => {
options.onSavingChange?.(inFlight !== null);
};
const startSave = (content: string): Promise<boolean> => {
const saveGeneration = generation;
options.onSaveStart?.(content);
const promise = (async () => {
try {
await options.onSave(content);
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveSuccess?.(content);
}
return true;
} catch (error) {
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveError?.(error);
}
return false;
}
})();
const entry = { content, promise };
inFlight = entry;
notifySavingChange();
void promise.finally(() => {
if (inFlight === entry) {
inFlight = null;
notifySavingChange();
}
});
return promise;
};
const save = async (content: string): Promise<boolean> => {
const current = inFlight;
if (current) {
const waitGeneration = generation;
const ok = await current.promise;
if (waitGeneration !== generation) return false;
if (!ok || current.content === content) return ok;
return save(content);
}
return startSave(content);
};
return {
save,
isSaving: () => inFlight !== null,
reset: () => {
generation += 1;
if (inFlight) {
inFlight = null;
notifySavingChange();
}
},
};
};

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
items: { length: 0 },
files,
} as unknown as DataTransfer;
}
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
const events: string[] = [];
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
const results = await uploadFromDataTransfer(
createDataTransfer([file]),
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
writeLocalFile: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onScanningStart: () => events.push("scan:start"),
onScanningEnd: () => events.push("scan:end"),
onTaskCreated: () => events.push("task:create"),
},
resolveConflict: async () => "skip",
},
);
assert.deepEqual(results, [
{ fileName: "conflict.txt", success: false, cancelled: true },
]);
assert.deepEqual(events, ["scan:start", "scan:end"]);
});

View File

@@ -16,14 +16,13 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
interface AutoSyncConfig {
@@ -35,7 +34,6 @@ interface AutoSyncConfig {
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -140,8 +138,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
@@ -150,7 +146,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
@@ -161,7 +156,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
@@ -283,7 +277,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
@@ -437,8 +431,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).

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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -49,7 +49,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -1265,6 +1265,7 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
@@ -1272,13 +1273,17 @@ export const useSettingsState = () => {
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,

View File

@@ -271,7 +271,7 @@ export const useSftpState = (
const {
transfers,
conflicts,
conflicts: transferConflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
@@ -282,7 +282,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveTransferConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
@@ -308,6 +308,8 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
@@ -322,6 +324,21 @@ export const useSftpState = (
dismissExternalUpload: dismissTransfer,
});
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
[transferConflicts, uploadConflicts],
);
const resolveAnyConflict = useCallback(
(...args: Parameters<typeof resolveTransferConflict>) => {
const [conflictId] = args;
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
return resolveUploadConflict(...args);
}
return resolveTransferConflict(...args);
},
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// Store methods in a ref to create stable wrapper functions
// This prevents callback reference changes from causing re-renders in consumers
const methodsRef = useRef({
@@ -375,7 +392,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
@@ -430,7 +447,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
@@ -496,7 +513,7 @@ export const useSftpState = (
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,

View File

@@ -0,0 +1,25 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const requestWindowInputFocus = (): void => {
try {
const result = netcattyBridge.get()?.windowFocus?.();
void result?.catch?.(() => undefined);
} catch {
// Browser preview or a disposed Electron bridge.
}
};
export const scheduleWindowInputFocus = (): void => {
const scheduleFrame: (callback: () => void) => unknown =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => {
callback();
return undefined;
};
scheduleFrame(() => {
requestWindowInputFocus();
setTimeout(requestWindowInputFocus, 50);
});
};

View File

@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const {
applyLocalVaultPayload,
applySyncPayload,
buildLocalVaultPayload,
buildSyncPayload,
hasMeaningfulCloudSyncData,
} = await import("./syncPayload.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
hostname: `${id}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${id}`,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts,
groupConfigs: [],
});
test.beforeEach(() => {
localStorage.clear();
});
test("buildSyncPayload treats known hosts as local-only data", () => {
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
assert.equal("knownHosts" in payload, false);
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-only")],
syncedAt: 1,
}),
false,
);
});
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("applySyncPayload ignores legacy cloud known hosts", () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
syncedAt: 1,
};
applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
});
test("applyLocalVaultPayload restores known hosts from local backups", () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-backup")],
syncedAt: 1,
};
applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
});

View File

@@ -58,7 +58,7 @@ import {
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
/** All vault-owned data that participates in cloud sync. */
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
@@ -66,6 +66,7 @@ export interface SyncableVaultData {
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
/** Local trust records. Kept in local backups, excluded from cloud sync. */
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
@@ -93,9 +94,31 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
);
}
/**
* Returns true when a payload contains cloud-sync data.
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
@@ -317,7 +340,6 @@ export function buildSyncPayload(
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
@@ -325,20 +347,30 @@ export function buildSyncPayload(
};
}
/** Build a local backup/restore payload, including local-only trust records. */
export function buildLocalVaultPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
...buildSyncPayload(vault, portForwardingRules),
knownHosts: vault.knownHosts,
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
@@ -349,7 +381,7 @@ export function applySyncPayload(
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
@@ -374,3 +406,17 @@ export function applySyncPayload(
importers.onSettingsApplied?.();
}
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: true });
}

View File

@@ -24,6 +24,7 @@ import type {
AIPanelView,
AIPermissionMode,
AIToolIntegrationMode,
AgentModelPreset,
AISession,
AISessionScope,
ChatMessage,
@@ -66,10 +67,22 @@ import {
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
}
return preset.id === modelId;
}
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
@@ -231,7 +244,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
@@ -608,12 +621,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
// ACP agents can expose their runtime model catalog during session setup.
// Codex also exposes model/reasoning selectors through ACP config options,
// which keeps the picker aligned with the user's installed CLI version.
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
@@ -640,13 +651,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
const runtimePresets = result.models ?? [];
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
[currentAgentId]: runtimePresets,
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
@@ -658,7 +669,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
@@ -668,7 +679,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
// Config.toml with a pinned model → show just that model.
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
@@ -678,13 +693,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// wouldn't work. Empty list disables the picker.
return [];
}
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
return stored;
}
// Default to first preset; for models with thinking levels, use the default level
@@ -698,6 +713,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
@@ -800,6 +821,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// immediately after the first send path starts; `isStreaming` alone does
// not protect the initial draft->session transition.
if (!trimmed || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
@@ -816,8 +841,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
try {
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
if (isDraftMode) {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
@@ -857,7 +880,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
@@ -1088,6 +1110,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}

View File

@@ -638,6 +638,7 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
@@ -1055,6 +1056,7 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onApplyLocalPayload,
onClearLocalData,
}) => {
const { t, resolvedLocale } = useI18n();
@@ -1916,7 +1918,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
/>
</div>
@@ -2612,6 +2614,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}

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

@@ -16,6 +16,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
@@ -47,6 +48,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 +69,7 @@ interface SftpSidePanelProps {
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -77,6 +80,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode,
activeHost,
initialLocation,
onInitialLocationApplied,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
@@ -91,6 +95,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
}) => {
const { t } = useI18n();
@@ -163,7 +168,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (id) owned.add(id);
}
if (owned.size === 0) return;
editorTabStore.forceCloseBySessions([...owned]);
const closed = editorTabStore.forceCloseBySessions([...owned]);
closed.forEach(releaseEditorTabSaveCoordinator);
};
}, []);
@@ -465,16 +471,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 +731,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onRequestTerminalFocus={onRequestTerminalFocus}
t={t}
/>
)}
@@ -751,6 +760,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

@@ -0,0 +1,138 @@
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 { TransferTask } from "../types.ts";
import { SftpTransferItem } from "./sftp/SftpTransferItem.tsx";
const baseTask: TransferTask = {
id: "transfer-1",
fileName: "archive.tar.gz",
sourcePath: "/local/archive.tar.gz",
targetPath: "/remote/archive.tar.gz",
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status: "failed",
totalBytes: 1024,
transferredBytes: 512,
speed: 0,
error: "Network error",
startTime: 1,
isDirectory: false,
};
const renderTransferItem = (
task: TransferTask,
props: Partial<React.ComponentProps<typeof SftpTransferItem>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(SftpTransferItem, {
task,
onCancel: () => {},
onRetry: () => {},
onDismiss: () => {},
...props,
}),
),
);
test("renders failed transfer actions with custom tooltips and readable labels", () => {
const markup = renderTransferItem(baseTask);
assert.match(markup, /aria-label="Retry: archive\.tar\.gz"/);
assert.match(markup, /aria-label="Dismiss: archive\.tar\.gz"/);
assert.match(markup, /focus-visible:ring-1/);
});
test("renders active transfer cancel action with an item-specific label", () => {
const markup = renderTransferItem({
...baseTask,
status: "transferring",
error: undefined,
speed: 128,
});
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
});
test("renders child resize handle as a keyboard-reachable separator", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-1",
parentTaskId: "transfer-1",
status: "transferring",
error: undefined,
transferredBytes: 256,
speed: 128,
},
{
isChild: true,
childNameColumnWidth: 260,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-label="Resize file name column"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /tabindex="0"/);
});
test("can remove duplicate child resize handles from the tab order", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-2",
parentTaskId: "transfer-1",
status: "pending",
error: undefined,
},
{
isChild: true,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
resizeHandleTabIndex: -1,
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /tabindex="-1"/);
});
test("keeps reveal target and child toggle as separate buttons", () => {
const markup = renderTransferItem(
{
...baseTask,
status: "completed",
error: undefined,
isDirectory: true,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canToggleChildren: true,
isExpanded: false,
childListId: "children-transfer-1",
onToggleChildren: () => {},
},
);
const revealStart = markup.indexOf('<button type="button" class="flex min-w-0 flex-1');
assert.notEqual(revealStart, -1);
const revealEnd = markup.indexOf("</button>", revealStart);
const toggleStart = markup.indexOf('aria-label="Show detail"');
assert.notEqual(toggleStart, -1);
assert.ok(toggleStart > revealEnd);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});

View File

@@ -26,6 +26,7 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
@@ -49,6 +50,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";
@@ -126,6 +128,8 @@ interface TerminalProps {
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: "theme" | "custom";
customAccent?: string;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -184,6 +188,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,
@@ -201,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
startupCommand,
@@ -658,18 +687,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// When "Follow Application Theme" is on and there's no active
// preview, skip per-host overrides — all terminals should use the
// UI-matched theme passed via terminalTheme prop.
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
if (followAppTerminalTheme && !themePreviewId) {
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
}
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
let baseTheme = terminalTheme;
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
if (hostTheme) baseTheme = hostTheme;
}
return terminalTheme;
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
chainHosts;
@@ -982,8 +1014,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 +1070,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
@@ -1689,8 +1733,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu

View File

@@ -24,6 +24,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
applyCustomAccentToTerminalTheme,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
@@ -43,6 +44,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 +55,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';
@@ -393,6 +396,8 @@ interface TerminalLayerProps {
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
@@ -436,6 +441,7 @@ interface TerminalLayerProps {
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
}
@@ -452,6 +458,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
accentMode = 'theme',
customAccent = '',
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
@@ -492,6 +500,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
toggleScriptsSidePanelRef,
activeSidePanelTabRef,
}) => {
// Subscribe to activeTabId from external store
@@ -793,6 +802,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 +1315,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 +1342,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 +1382,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 +1437,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');
@@ -1523,35 +1585,37 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!pane || !theme) {
if (!pane || !baseTheme) {
clearTerminalPreviewVars(sessionId);
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
}, [customThemes]);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.cursor} 78%, ${theme.colors.background} 22%)`);
}, [accentMode, customAccent, customThemes]);
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
if (!themeId || typeof document === 'undefined') {
clearTopTabsPreviewVars();
return;
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!tabsRoot || !theme) {
if (!tabsRoot || !baseTheme) {
clearTopTabsPreviewVars();
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = fg;
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
@@ -1568,8 +1632,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
}, [customThemes]);
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--accent))');
}, [accentMode, customAccent, customThemes]);
useEffect(() => {
return () => {
@@ -1832,10 +1896,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const resolvedPreviewTheme = useMemo(() => {
const themeId = previewedOrVisibleThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
const baseTheme = TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, previewedOrVisibleThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
@@ -2144,6 +2209,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
style={{
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
['--terminal-sidepanel-accent' as never]: resolvedPreviewTheme.colors.cursor,
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
backgroundColor: 'var(--terminal-sidepanel-bg)',
@@ -2166,6 +2232,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'sftp'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2183,6 +2252,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'scripts'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2200,6 +2272,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'theme'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2217,6 +2292,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2271,6 +2349,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 +2364,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}
/>
);
})}
@@ -2466,6 +2546,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
@@ -2582,6 +2664,8 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
@@ -2599,6 +2683,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,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorModalSnapshot } from "./TextEditorModal.tsx";
import { createTextEditorSaveCoordinator } from "../application/state/textEditorSaveCoordinator.ts";
test("promotion snapshot uses the latest saved baseline after a save", async () => {
let baselineContent = "old";
let content = "saved";
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {},
onSaveSuccess: (savedContent) => {
baselineContent = savedContent;
},
});
await coordinator.save(content);
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => baselineContent,
getContent: () => content,
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => false,
});
assert.equal(snapshot?.baselineContent, "saved");
assert.equal(snapshot?.content, "saved");
});
test("promotion snapshot is blocked while saving", () => {
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => "old",
getContent: () => "new",
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => true,
});
assert.equal(snapshot, null);
});

View File

@@ -9,14 +9,20 @@ import { getLanguageId } from '../lib/sftpFileUtils';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { toast } from './ui/toast';
import { TextEditorPane } from './editor/TextEditorPane';
import { promptUnsavedChanges } from './editor/UnsavedChangesDialog';
import { useI18n } from '../application/i18n/I18nProvider';
import { scheduleWindowInputFocus } from '../application/state/windowInputFocus';
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from '../application/state/textEditorSaveCoordinator';
import type { HotkeyScheme, KeyBinding } from '../domain/models';
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
export interface TextEditorModalSnapshot {
/** The file name at the time of promotion (modal's fileName prop). */
fileName: string;
/** The clean baseline content at the time the modal was opened. */
/** The clean baseline content at the time of promotion. */
baselineContent: string;
/** The current (possibly-dirty) editor content. */
content: string;
@@ -28,6 +34,31 @@ export interface TextEditorModalSnapshot {
viewState: Monaco.editor.ICodeEditorViewState | null;
}
export interface TextEditorModalSnapshotSource {
fileName: string;
getBaselineContent: () => string;
getContent: () => string;
languageId: string;
wordWrap: boolean;
getViewState: () => Monaco.editor.ICodeEditorViewState | null;
isSaving: () => boolean;
}
export const createTextEditorModalSnapshot = (
source: TextEditorModalSnapshotSource,
): TextEditorModalSnapshot | null => {
if (source.isSaving()) return null;
return {
fileName: source.fileName,
baselineContent: source.getBaselineContent(),
content: source.getContent(),
languageId: source.languageId,
wordWrap: source.wordWrap,
viewState: source.getViewState(),
};
};
interface TextEditorModalProps {
open: boolean;
onClose: () => void;
@@ -57,51 +88,128 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const { t } = useI18n();
const [content, setContent] = useState(initialContent);
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
const closePromptRef = useRef<Promise<void> | null>(null);
const onSaveRef = useRef(onSave);
const tRef = useRef(t);
const saveCoordinatorRef = useRef<TextEditorSaveCoordinator | null>(null);
// Latest view state captured from Pane's onContentChange — used by handlePromote
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
// Derived: whether the current content differs from the clean baseline
const hasChanges = content !== initialContent;
const hasChanges = content !== baselineContent;
if (!saveCoordinatorRef.current) {
saveCoordinatorRef.current = createTextEditorSaveCoordinator({
onSave: (contentToSave) => onSaveRef.current(contentToSave),
onSaveStart: () => {
setSaveError(null);
},
onSaveSuccess: (savedContent) => {
setBaselineContent(savedContent);
baselineContentRef.current = savedContent;
toast.success(tRef.current('sftp.editor.saved'), 'SFTP');
},
onSaveError: (error) => {
const msg = error instanceof Error
? error.message
: tRef.current('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
},
onSavingChange: (nextSaving) => {
savingRef.current = nextSaving;
setSaving(nextSaving);
},
});
}
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
tRef.current = t;
}, [t]);
// Reset all state when a new file is opened
useEffect(() => {
saveCoordinatorRef.current?.reset();
setContent(initialContent);
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
}, []);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(content);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
await saveContent();
}, [saveContent]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
if (!confirmed) return;
if (closePromptRef.current) return;
const closeTask = (async () => {
if (contentRef.current !== baselineContentRef.current) {
const choice = await promptUnsavedChanges(fileName);
if (choice === 'cancel') return;
if (choice === 'save') {
const saved = await saveContent();
if (!saved) return;
if (contentRef.current !== baselineContentRef.current) return;
}
}
onClose();
scheduleWindowInputFocus();
})().finally(() => {
closePromptRef.current = null;
});
closePromptRef.current = closeTask;
}, [fileName, onClose, saveContent]);
useEffect(() => {
contentRef.current = content;
}, [content]);
useEffect(() => {
baselineContentRef.current = baselineContent;
}, [baselineContent]);
useEffect(() => {
savingRef.current = saving;
}, [saving]);
useEffect(() => {
if (!open) {
closePromptRef.current = null;
}
onClose();
}, [hasChanges, onClose, t]);
}, [open]);
useEffect(() => {
if (open) scheduleWindowInputFocus();
}, [open]);
const handleContentChange = useCallback(
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
setContent(nextContent);
contentRef.current = nextContent;
viewStateRef.current = viewState;
},
[],
@@ -109,15 +217,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const handlePromote = useCallback(() => {
if (!onPromoteToTab) return;
onPromoteToTab({
const snapshot = createTextEditorModalSnapshot({
fileName,
baselineContent: initialContent,
content,
getBaselineContent: () => baselineContentRef.current,
getContent: () => contentRef.current,
languageId,
wordWrap: editorWordWrap,
viewState: viewStateRef.current,
getViewState: () => viewStateRef.current,
isSaving: () => savingRef.current,
});
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
if (snapshot) onPromoteToTab(snapshot);
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>

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',
'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',
'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-[7px]',
'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
@@ -233,7 +233,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>

View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { canSendWithAgent, findEnabledExternalAgent } from './agentSendEligibility';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
const agents: ExternalAgentConfig[] = [
{
id: 'enabled-agent',
name: 'Enabled Agent',
command: '/usr/local/bin/enabled-agent',
enabled: true,
},
{
id: 'disabled-agent',
name: 'Disabled Agent',
command: '/usr/local/bin/disabled-agent',
enabled: false,
},
];
test('canSendWithAgent allows Catty and enabled external agents', () => {
assert.equal(canSendWithAgent('catty', agents), true);
assert.equal(canSendWithAgent('enabled-agent', agents), true);
});
test('canSendWithAgent blocks missing or disabled external agents', () => {
assert.equal(canSendWithAgent('disabled-agent', agents), false);
assert.equal(canSendWithAgent('missing-agent', agents), false);
});
test('findEnabledExternalAgent ignores disabled external agents', () => {
assert.equal(findEnabledExternalAgent(agents, 'enabled-agent')?.name, 'Enabled Agent');
assert.equal(findEnabledExternalAgent(agents, 'disabled-agent'), undefined);
});

View File

@@ -0,0 +1,15 @@
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
export function findEnabledExternalAgent(
agents: ExternalAgentConfig[],
agentId: string,
): ExternalAgentConfig | undefined {
return agents.find((agent) => agent.id === agentId && agent.enabled);
}
export function canSendWithAgent(
agentId: string,
agents: ExternalAgentConfig[],
): boolean {
return agentId === "catty" || Boolean(findEnabledExternalAgent(agents, agentId));
}

View File

@@ -31,6 +31,18 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
isProviderContinuationForSource,
mergeProviderContinuation,
normalizeProviderContinuationOptions,
withProviderContinuationSource,
type OpenAIChatAssistantFields,
type ProviderContinuation,
type ProviderContinuationOptions,
type ProviderContinuationSource,
} from '../../../infrastructure/ai/providerContinuation';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -41,12 +53,22 @@ interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
providerMetadata?: unknown;
}
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
textDelta?: string;
delta?: string;
providerMetadata?: unknown;
}
/** Shape of a raw provider chunk from the Vercel AI SDK fullStream. */
interface RawChunk {
type: 'raw';
rawValue: unknown;
}
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
@@ -56,6 +78,7 @@ interface ToolCallChunk {
toolName: string;
input?: unknown;
args?: unknown;
providerMetadata?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
@@ -105,6 +128,7 @@ type StreamChunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
| RawChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
@@ -119,7 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
@@ -153,6 +177,23 @@ export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
interface CattyProviderContinuationContext {
source: ProviderContinuationSource;
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
}
type AssistantContentPart =
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
return parts[0].text;
}
return parts;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -251,6 +292,7 @@ export interface UseAIChatStreamingReturn {
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
@@ -389,6 +431,7 @@ export function useAIChatStreaming({
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
): Promise<void> => {
const result = streamText({
model,
@@ -397,6 +440,7 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
includeRawChunks: true,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
@@ -412,6 +456,42 @@ export function useAIChatStreaming({
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
let rafId: number | null = null;
const ensureAssistantMessage = (): string => {
if (lastAddedRole !== 'tool') return activeMsgId;
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
return activeMsgId;
};
const updateAssistantContinuation = (
messageId: string,
continuation: ProviderContinuation | undefined,
thinkingText = '',
) => {
if (!continuation && !thinkingText) return;
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
updateMessageById(streamSessionId, messageId, msg => {
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
return {
...msg,
...(providerContinuation ? { providerContinuation } : {}),
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
};
});
};
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
return typeof reasoningContent === 'string' ? reasoningContent : '';
};
const flushText = () => {
if (pendingText) {
@@ -455,6 +535,11 @@ export function useAIChatStreaming({
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
if (providerOptions) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
}
if (text) {
pendingText += text;
if (rafId === null) {
@@ -469,25 +554,30 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text;
if (rText) {
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
thinking: rText,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
thinking: (msg.thinking || '') + rText,
}));
}
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
const continuation = rText || providerOptions
? {
reasoningParts: [{
text: rText,
...(providerOptions ? { providerOptions } : {}),
}],
} satisfies ProviderContinuation
: undefined;
if (continuation || rText) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, rText);
}
break;
}
case 'raw': {
const typedChunk = chunk as RawChunk;
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
if (continuation) {
cancelPendingFlush();
flushText();
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
}
break;
}
@@ -503,7 +593,9 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
updateMessageById(streamSessionId, messageId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
@@ -513,6 +605,13 @@ export function useAIChatStreaming({
executionStatus: 'running',
statusText: undefined,
}));
if (providerOptions) {
updateAssistantContinuation(messageId, {
toolCallProviderOptionsById: {
[typedChunk.toolCallId]: providerOptions,
},
});
}
break;
}
case 'tool-result': {
@@ -778,20 +877,15 @@ export function useAIChatStreaming({
return;
}
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig({
...context.activeProvider,
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
const continuationContext: CattyProviderContinuationContext = {
source: {
providerConfigId: context.activeProvider.id,
providerType: context.activeProvider.providerId,
modelId: activeModelId,
},
openAIChatAssistantFields: [],
};
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
@@ -818,7 +912,9 @@ export function useAIChatStreaming({
};
const sdkMessages: Array<ModelMessage> = [];
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
@@ -837,30 +933,76 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
if (resolvedCalls.length > 0) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
sdkMessages.push({
role: 'assistant',
content: toAssistantModelContent(contentParts),
});
if (currentMessageFollowsToolResult) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
@@ -873,6 +1015,7 @@ export function useAIChatStreaming({
})),
});
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
@@ -890,7 +1033,37 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig(
{
...context.activeProvider,
defaultModel: activeModelId,
},
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
},
);
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
sdkMessages,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_codex',
name: 'Codex CLI',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'discovered_codex',
'codex',
{ path: '/usr/local/bin/codex', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'catty');
});
test('buildManagedAgentState keeps unrelated defaults when removing stale managed agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_claude',
name: 'Claude Code',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'custom-agent',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-claude-wrapper',
name: 'My Claude Wrapper',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-claude-wrapper',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(state.agents, agents);
assert.equal(state.defaultAgentId, 'my-claude-wrapper');
});
test('buildManagedAgentState only rewrites settings-managed discovered agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-codex-wrapper',
name: 'My Codex Wrapper',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-codex-wrapper',
'codex',
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['my-codex-wrapper', 'discovered_codex'],
);
assert.equal(state.agents[0], agents[0]);
assert.equal(state.defaultAgentId, 'my-codex-wrapper');
});

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
canPromoteTextEditor,
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
assert.equal(isTextEditorReadOnly({ saving: true }), true);
assert.equal(isTextEditorReadOnly({ saving: false }), false);
});
test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
assert.match(savingMarkup, /disabled=""/);
assert.doesNotMatch(idleMarkup, /disabled=""/);
});

View File

@@ -16,9 +16,10 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
const viteEnv = import.meta.env ?? { BASE_URL: "/" };
const monacoBasePath = viteEnv.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
: `${viteEnv.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -116,6 +117,9 @@ const hslToHex = (hslString: string): string => {
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
@@ -143,6 +147,9 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return '';
}
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
@@ -170,6 +177,27 @@ export interface TextEditorPaneProps {
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
}
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
title={title}
>
<Maximize2 size={14} />
</Button>
);
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
fileName,
content,
@@ -202,7 +230,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever immersive-mode or base theme colors change
@@ -253,6 +281,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
@@ -309,6 +338,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
if (saving) return;
const editor = editorRef.current;
if (!editor) return;
@@ -337,16 +367,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
})),
);
editor.focus();
}, [readClipboardText]);
}, [readClipboardText, saving]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleEditorChange = useCallback((value: string | undefined) => {
if (saving) return;
const editor = editorRef.current;
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
}, [onContentChange]);
}, [onContentChange, saving]);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
@@ -504,15 +535,11 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
{chrome === 'modal' && onPromoteToTab && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
<TextEditorPromoteButton
saving={saving}
onPromoteToTab={onPromoteToTab}
title={t('sftp.editor.maximize')}
>
<Maximize2 size={14} />
</Button>
/>
)}
{/* Close button — modal chrome only */}
@@ -556,6 +583,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
tabSize: 2,
insertSpaces: true,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: isTextEditorReadOnly({ saving }),
domReadOnly: isTextEditorReadOnly({ saving }),
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },

View File

@@ -8,7 +8,7 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
import { saveEditorTab } from '../../application/state/editorTabSave';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
@@ -60,21 +60,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
}, [tabId]);
const handleSave = useCallback(async () => {
// Read live store state at call time — React state snapshot lags the store
// by one microtask, so a keystroke between onChange and this save would
// otherwise leave us writing stale content and marking a stale baseline.
const current = editorTabStore.getTab(tabId);
if (!current) return;
if (current.savingState === 'saving') return;
editorTabStore.setSavingState(tabId, 'saving');
try {
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
editorTabStore.markSaved(tabId, current.content);
const ok = await saveEditorTab(tabId);
if (ok) {
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
editorTabStore.setSavingState(tabId, 'error', msg);
} else {
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
toast.error(msg, 'SFTP');
}
}, [tabId, t]);

View File

@@ -17,11 +17,7 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
@@ -36,7 +32,6 @@ import type {
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
@@ -48,6 +43,11 @@ import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
import {
areExternalAgentListsEqual,
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
// ---------------------------------------------------------------------------
// Props
@@ -80,54 +80,6 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -179,11 +131,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);

View File

@@ -1,7 +1,12 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import {
applyLocalVaultPayload,
buildLocalVaultPayload,
buildSyncPayload,
applySyncPayload,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
@@ -29,7 +34,7 @@ export default function SettingsSyncTab(props: {
} = props;
const { t } = useI18n();
const onBuildPayload = useCallback((): SyncPayload => {
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
@@ -51,15 +56,26 @@ export default function SettingsSyncTab(props: {
}
}
return effectiveRules;
}, [portForwardingRules]);
const onBuildPayload = useCallback((): SyncPayload => {
return buildSyncPayload(vault, getEffectivePortForwardingRules());
}, [vault, getEffectivePortForwardingRules]);
const onBuildLocalPayload = useCallback((): SyncPayload => {
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
return buildLocalVaultPayload(
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
getEffectivePortForwardingRules(),
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
@@ -69,7 +85,23 @@ export default function SettingsSyncTab(props: {
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const onApplyLocalPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applyLocalVaultPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {
@@ -82,6 +114,7 @@ export default function SettingsSyncTab(props: {
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onApplyLocalPayload={onApplyLocalPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>

View File

@@ -1034,6 +1034,17 @@ 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>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />

View File

@@ -0,0 +1,72 @@
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
import {
type ManagedAgentKey,
} from "../../../../infrastructure/ai/managedAgents";
import type { AgentPathInfo } from "./types";
import { AGENT_DEFAULTS } from "./types";
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || "").trim();
return normalized.includes("/") || normalized.includes("\\");
}
function getAutoManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
}
export function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
export function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: otherAgents,
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
return {
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
};
}

View File

@@ -7,12 +7,16 @@ import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
interface ConflictItem {
transferId: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;
@@ -21,7 +25,7 @@ interface ConflictItem {
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: 'replace' | 'skip' | 'duplicate') => void;
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
@@ -36,13 +40,14 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return new Date(timestamp).toLocaleString();
};
const handleAction = (action: 'replace' | 'skip' | 'duplicate') => {
if (applyToAll) {
// Apply to all conflicts
conflicts.forEach(c => onResolve(c.transferId, action));
} else {
onResolve(conflict.transferId, action);
}
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
setApplyToAll(false);
};
@@ -95,7 +100,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
</div>
</div>
{conflicts.length > 1 && (
{sameTypeConflictCount > 1 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
@@ -103,12 +108,19 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onChange={(e) => setApplyToAll(e.target.checked)}
className="rounded border-border"
/>
{t('sftp.conflict.applyToAll', { count: conflicts.length })}
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
</label>
)}
</div>
<DialogFooter className="flex gap-2">
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
<Button
variant="destructive"
onClick={() => handleAction('stop')}
className="flex-1"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
@@ -121,8 +133,18 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onClick={() => handleAction('duplicate')}
className="flex-1"
>
{t('sftp.conflict.action.keepBoth')}
{t('sftp.conflict.action.duplicate')}
</Button>
{conflict.isDirectory && (
<Button
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="flex-1"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
<Button
variant="default"
onClick={() => handleAction('replace')}

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

@@ -39,24 +39,39 @@ interface SftpTransferItemProps {
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
onSetNameColumnWidth?: (width: number) => void;
childNameColumnMinWidth?: number;
childNameColumnMaxWidth?: number;
childListId?: string;
resizeHandleTabIndex?: number;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
);
const IconButtonWithTooltip: React.FC<{
label: string;
children: React.ReactElement;
}> = ({ label, children }) => (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
@@ -73,6 +88,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
onSetNameColumnWidth,
childNameColumnMinWidth = 160,
childNameColumnMaxWidth = 480,
childListId,
resizeHandleTabIndex = 0,
}) => {
const { t } = useI18n();
@@ -184,29 +204,65 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
const hasFooterContent = showTransferSizeCalculation || showFailedError;
const retryActionLabel = t('sftp.transfers.retryAction');
const cancelActionLabel = t('common.cancel');
const dismissActionLabel = t('sftp.transfers.dismissAction');
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
const setNameColumnWidth = (width: number) => {
const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width));
onSetNameColumnWidth?.(nextWidth);
};
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onSetNameColumnWidth) return;
const step = event.shiftKey ? 40 : 10;
if (event.key === 'ArrowLeft') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth - step);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth + step);
} else if (event.key === 'Home') {
event.preventDefault();
setNameColumnWidth(childNameColumnMinWidth);
} else if (event.key === 'End') {
event.preventDefault();
setNameColumnWidth(childNameColumnMaxWidth);
}
};
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
<RefreshCw size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
<IconButtonWithTooltip label={cancelActionLabel}>
<Button variant="ghost" size="icon" className={cn(actionButtonClass, "text-destructive hover:text-destructive")} onClick={onCancel} aria-label={actionAriaLabel(cancelActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
<IconButtonWithTooltip label={dismissActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
</div>
);
if (isChild) {
return (
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
style={{
@@ -222,13 +278,25 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
onMouseDown={onResizeNameColumn}
title="Resize file name column"
>
<GripVertical size={10} />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onMouseDown={onResizeNameColumn}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-label={resizeNameColumnLabel}
aria-orientation="vertical"
aria-valuemin={childNameColumnMinWidth}
aria-valuemax={childNameColumnMaxWidth}
aria-valuenow={childNameColumnWidth}
tabIndex={resizeHandleTabIndex}
>
<GripVertical size={10} />
</div>
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0">
{childProgressBar}
</div>
@@ -236,12 +304,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{actionButtons}
</div>
</div>
);
}
) : (() => {
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const titleBlock = (
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
@@ -255,21 +321,29 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
{canToggleChildren && (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggleChildren}
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
>
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
)}
</div>
);
);
return (
const toggleChildrenButton = canToggleChildren ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onToggleChildren}
aria-label={toggleChildrenLabel}
aria-expanded={isExpanded}
aria-controls={childListId}
>
{toggleChildrenLabel}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</TooltipTrigger>
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
</Tooltip>
) : null;
return (
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
@@ -290,6 +364,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
@@ -341,6 +417,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
</div>
);
})();
return (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
{content}
</TooltipProvider>
);
};
@@ -362,6 +445,10 @@ const arePropsEqual = (
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;

View File

@@ -29,9 +29,11 @@ const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
const childListIdForTask = (taskId: string) => `sftp-transfer-children-${taskId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
interface TransferChildListProps {
childTasks: TransferTask[];
childListId: string;
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
@@ -40,10 +42,12 @@ interface TransferChildListProps {
onCancel: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
onSetNameColumnWidth: (width: number) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childListId,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
@@ -52,6 +56,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
onCancel,
onRetry,
onDismiss,
onSetNameColumnWidth,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
@@ -102,6 +107,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
return (
<div
id={childListId}
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
@@ -121,7 +127,11 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
task={child}
isChild
childNameColumnWidth={childNameWidth}
childNameColumnMinWidth={MIN_CHILD_NAME_WIDTH}
childNameColumnMaxWidth={MAX_CHILD_NAME_WIDTH}
onResizeNameColumn={onResizeNameColumn}
onSetNameColumnWidth={onSetNameColumnWidth}
resizeHandleTabIndex={visibleIndex === 0 ? 0 : -1}
onCancel={() => onCancel(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
@@ -303,6 +313,12 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
document.body.style.userSelect = "none";
}, [childNameWidth]);
const handleChildColumnWidthSet = useCallback((width: number) => {
const nextWidth = Math.max(MIN_CHILD_NAME_WIDTH, Math.min(MAX_CHILD_NAME_WIDTH, width));
setChildNameWidth(nextWidth);
persistChildNameWidth(nextWidth);
}, [persistChildNameWidth, setChildNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
@@ -369,6 +385,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
const childListId = childListIdForTask(task.id);
return (
<React.Fragment key={task.id}>
@@ -377,6 +394,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
childListId={childListId}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
if (task.sourceConnectionId === "external") {
@@ -399,8 +417,10 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childListId={childListId}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
onSetNameColumnWidth={handleChildColumnWidthSet}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}

View File

@@ -5,6 +5,7 @@ import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewPaneActionsParams {
@@ -139,12 +140,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("left");
@@ -155,12 +162,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("right");

View File

@@ -2,6 +2,10 @@ import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
@@ -23,8 +27,8 @@ interface UseSftpViewTabsResult {
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleCloseTabLeft: (tabId: string) => Promise<void>;
handleCloseTabRight: (tabId: string) => Promise<void>;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
@@ -53,13 +57,41 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {
sftpRef.current.closeTab("left", tabId);
}, [sftpRef]);
const confirmCloseEditorTabsByConnection = useCallback(async (connectionId: string): Promise<boolean> => {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, []);
const handleCloseTabRight = useCallback((tabId: string) => {
sftpRef.current.closeTab("right", tabId);
}, [sftpRef]);
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const connectionId = pane?.connection?.id;
if (connectionId) {
const ok = await confirmCloseEditorTabsByConnection(connectionId);
if (!ok) return;
}
sftpRef.current.closeTab(side, tabId);
}, [confirmCloseEditorTabsByConnection, sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => (
handleCloseSftpTab("left", tabId)
), [handleCloseSftpTab]);
const handleCloseTabRight = useCallback((tabId: string) => (
handleCloseSftpTab("right", tabId)
), [handleCloseSftpTab]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);

View File

@@ -47,3 +47,72 @@ test("still trims prompt decorations out of the detected input", () => {
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, "do");
});
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
// Real-world PS1 captured from oh-my-posh themed bash on a server:
// "<U+F31B> root@oracle ~ <U+F105> " then user input
const term = createFakeTerm(" root@oracle ~  ls", 21);
const result = getAlignedPrompt(term as never, "ls", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root@oracle ~  ");
assert.equal(result.prompt.userInput, "ls");
});
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
const term = createFakeTerm(" root  ~  git", 16);
const result = getAlignedPrompt(term as never, "git", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "git");
assert.ok(result.prompt.promptText.endsWith(" "));
});
test("PUA char without trailing space is not a prompt boundary", () => {
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
const term = createFakeTerm("echo foo", 13);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, false);
});
test("keeps typed command intact when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = `$ ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("prefers standard prompt terminator over later Powerline glyphs", () => {
const lineText = "$ echo  foo";
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo  foo");
});
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = ` root  ~  ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root  ~  ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});

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,71 @@
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",
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalToolbar, {
status,
host,
onOpenSFTP: () => {},
onOpenScripts: () => {},
onOpenTheme: () => {},
...props,
}),
),
);
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);
});
test("uses the terminal active button color for pressed toolbar actions", () => {
const markup = renderToolbar(sshHost, "connected", {
isSearchOpen: true,
onToggleSearch: () => {},
});
assert.match(
markup,
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});

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';
@@ -71,6 +71,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const hidesSftp = isLocalTerminal || isSerialTerminal;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
const activeButtonStyle: React.CSSProperties = {
backgroundColor: 'var(--terminal-toolbar-btn-active)',
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
@@ -82,6 +85,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
@@ -91,6 +114,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
style={isComposeBarOpen ? activeButtonStyle : undefined}
>
<TextCursorInput size={12} />
</Button>
@@ -107,6 +131,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
style={isSearchOpen ? activeButtonStyle : undefined}
>
<Search size={12} />
</Button>
@@ -114,9 +139,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 +179,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

@@ -1,8 +1,8 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Command history (highest priority)
* 2. @withfig/autocomplete specs (subcommands, options, args)
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
@@ -66,6 +66,11 @@ export interface CompletionContext {
isOptionArg: boolean;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
@@ -170,10 +175,13 @@ export async function getCompletions(
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, undefined)
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
@@ -226,21 +234,16 @@ export async function getCompletions(
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const specPromise = ctx.commandName && ctx.wordIndex >= 0
? getSpecSuggestions(ctx)
: Promise.resolve([]);
const pathPromise = canQueryPaths && pathCheck.shouldComplete
? getPathSuggestions(ctx, {
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})
: Promise.resolve([]);
: [];
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
for (const suggestion of specSugs) {
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
@@ -313,26 +316,26 @@ function normalizeHistoryPathPrefix(token: string): string {
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return await getCommandNameSuggestions(ctx.currentWord);
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return [];
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return [];
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return [];
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
@@ -348,11 +351,11 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
if (suggestions.length >= 10) break;
}
}
return suggestions;
return { suggestions };
}
// Navigate the spec tree based on typed tokens
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
@@ -387,7 +390,7 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return suggestions;
return { suggestions };
}
}
@@ -442,7 +445,10 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
}
}
return suggestions;
return {
suggestions,
pathArgs: resolved.args,
};
}
/**

View File

@@ -3,8 +3,8 @@
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
* etc.) and choose the most reliable split for prompt text vs. user input.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
@@ -62,6 +62,16 @@ function replacePromptUserInput(
};
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return null;
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
@@ -141,9 +151,23 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
/** Characters that commonly end a shell prompt */
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);
/**
* Whether a character lives in the Unicode Private Use Area (U+E000U+F8FF).
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
* fall here. A PUA char followed by a space is common in themed prompt
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
* characters and reconciled with the typed buffer when available.
*/
function isPuaChar(ch: string): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
@@ -154,15 +178,18 @@ function findPromptBoundary(lineText: string): number {
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastBoundary = -1;
let lastStandardBoundary = -1;
let lastPuaBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
const isStandard = PROMPT_CHARS.has(ch);
const isPua = !isStandard && isPuaChar(ch);
if (!PROMPT_CHARS.has(ch)) continue;
if (!isStandard && !isPua) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
@@ -222,11 +249,17 @@ function findPromptBoundary(lineText: string): number {
}
}
// Record this as a candidate boundary
lastBoundary = nextChar === " " ? i + 2 : i + 1;
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
if (isStandard) {
lastStandardBoundary = boundary;
} else {
lastPuaBoundary = boundary;
}
}
return lastBoundary;
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
}
/**
@@ -312,6 +345,21 @@ export function getAlignedPrompt(
alignedTyped: typedBuffer,
};
}
const cursorLinePrefix = getCursorLinePrefix(term);
if (cursorLinePrefix?.endsWith(typedBuffer)) {
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
if (promptText.length > 0) {
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -107,11 +107,6 @@ export function shouldDoPathCompletion(
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
// Generators field often indicates path completion (e.g., cd)
if (arg.generators) {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
}
}

View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
type MockDirEntry = {
name: string;
type: "file" | "directory" | "symlink";
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const storySpec: FigSpec = {
name: "story",
subcommands: [
{
name: "open",
args: { template: "filepaths" },
},
{
name: "pick",
args: { name: "item", generators: {} },
},
],
};
const bridgeState: { localEntries: MockDirEntry[] } = {
localEntries: [],
};
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => ["story"],
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
listAutocompleteLocalDir: async (
_path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
const prefix = (filterPrefix ?? "").toLowerCase();
const entries = bridgeState.localEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? bridgeState.localEntries.length);
return { success: true, entries };
},
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
});
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
recordCommand("story open package-lock.json", "host-1");
const completions = await getCompletions("story open pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "story open package.json");
const historyIndex = completions.findIndex((entry) =>
entry.source === "history" && entry.text === "story open package-lock.json"
);
assert.ok(historyIndex > 0);
});
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
recordCommand("story pick package-choice", "host-1");
const completions = await getCompletions("story pick pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "history");
assert.equal(completions[0]?.text, "story pick package-choice");
assert.equal(completions.some((entry) => entry.source === "path"), false);
});

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

@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {
terminalEmulationType: "xterm-256color",
moshClientPath: "/usr/local/bin/mosh-client",
},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal("moshClientPath" in capturedOptions, false);
assert.equal(capturedOptions.hostname, "example.test");
assert.equal(capturedOptions.port, 2200);
});
test("startMosh passes the saved password to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "saved-secret",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.username, "alice");
assert.equal(capturedOptions.password, "saved-secret");
});

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,
@@ -752,11 +754,28 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
keys: ctx.keys,
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: ctx.host.username || "root",
username: resolvedAuth.username || "root",
password: effectivePassword,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
agentForwarding: ctx.host.agentForwarding,

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,12 @@ 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
// Legacy override retained for old settings payloads and internal callers.
// The normal UI path uses Netcatty's bundled mosh-client.
moshClientPath: string;
// Server Stats Display (Linux only)
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
@@ -635,6 +642,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: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
@@ -771,6 +780,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
export interface TransferTask {
id: string;
batchId?: string;
fileName: string;
originalFileName?: string;
sourcePath: string;
@@ -795,14 +805,21 @@ export interface TransferTask {
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
export type FileConflictAction = 'stop' | 'skip' | 'replace' | 'duplicate' | 'merge';
export interface FileConflict {
transferId: string;
batchId?: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;

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

@@ -156,13 +156,11 @@ test("only non-hosts entity shrinks → reports that entity", () => {
}
});
test("knownHosts shrink triggers (security-sensitive)", () => {
test("knownHosts shrink is ignored because known hosts are local-only", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });
const out = payload({ knownHosts: kh(2) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {

View File

@@ -12,7 +12,6 @@ export type ShrinkFinding =
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'knownHosts'
| 'portForwardingRules'
| 'groupConfigs';
baseCount: number;
@@ -32,7 +31,6 @@ const CHECKED_ENTITIES = [
'snippets',
'customGroups',
'snippetPackages',
'knownHosts',
'portForwardingRules',
'groupConfigs',
] as const;

40
domain/syncMerge.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mergeSyncPayloads } from "./syncMerge.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
const knownHosts = (n: number): SyncPayload["knownHosts"] =>
Array.from({ length: n }, (_, i) => ({
id: `kh-${i}`,
hostname: `host-${i}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${i}`,
})) as SyncPayload["knownHosts"];
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
const result = mergeSyncPayloads(
payload({ knownHosts: knownHosts(2) }),
payload(),
payload({ knownHosts: knownHosts(3) }),
);
assert.equal("knownHosts" in result.payload, false);
});

View File

@@ -347,7 +347,6 @@ export function mergeSyncPayloads(
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
@@ -365,19 +364,6 @@ export function mergeSyncPayloads(
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
const knownHostSeen = new Set<string>();
const knownHosts = {
...knownHostsRaw,
merged: knownHostsRaw.merged.filter((kh) => {
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
if (knownHostSeen.has(fp)) return false;
knownHostSeen.add(fp);
return true;
}),
};
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
@@ -394,7 +380,7 @@ export function mergeSyncPayloads(
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -437,7 +423,6 @@ export function mergeSyncPayloads(
snippets: snippets.merged,
customGroups,
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
settings,

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
import type { TerminalTheme } from "./models";
const baseTheme: TerminalTheme = {
id: "ui-snow",
name: "Snow",
type: "light",
colors: {
background: "#f1f4f8",
foreground: "#24292f",
cursor: "#0969da",
selection: "#add6ff",
black: "#24292f",
red: "#cf222e",
green: "#116329",
yellow: "#9a6700",
blue: "#0969da",
magenta: "#8250df",
cyan: "#0e7574",
white: "#6e7781",
brightBlack: "#57606a",
brightRed: "#a40e26",
brightGreen: "#1a7f37",
brightYellow: "#7d4e00",
brightBlue: "#218bff",
brightMagenta: "#a475f9",
brightCyan: "#0c7875",
brightWhite: "#8c959f",
},
};
test("applies a custom accent to terminal cursor and selection colors", () => {
const accented = applyCustomAccentToTerminalTheme(baseTheme, "custom", "160 70% 40%");
assert.notEqual(accented, baseTheme);
assert.equal(accented.colors.cursor, "#1fad7e");
assert.equal(accented.colors.selection, "#b1f1dc");
assert.equal(baseTheme.colors.cursor, "#0969da");
assert.equal(baseTheme.colors.selection, "#add6ff");
});
test("keeps terminal theme unchanged without a valid custom accent", () => {
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
});

View File

@@ -1,4 +1,4 @@
import { Host } from './models';
import { Host, TerminalTheme } from './models';
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -69,6 +69,95 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
type ParsedHslToken = {
hue: number;
saturation: number;
lightness: number;
};
const parseHslToken = (value: string): ParsedHslToken | null => {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
if (!match) return null;
const hue = Number(match[1]);
const saturation = Number(match[2]);
const lightness = Number(match[3]);
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
if (saturation < 0 || saturation > 100 || lightness < 0 || lightness > 100) return null;
return {
hue: ((hue % 360) + 360) % 360,
saturation,
lightness,
};
};
const toHexChannel = (value: number): string =>
Math.round(Math.max(0, Math.min(255, value)))
.toString(16)
.padStart(2, '0');
const hslToHex = ({ hue, saturation, lightness }: ParsedHslToken): string => {
const s = saturation / 100;
const l = lightness / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = hue / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
let r = 0;
let g = 0;
let b = 0;
if (hp < 1) {
r = c;
g = x;
} else if (hp < 2) {
r = x;
g = c;
} else if (hp < 3) {
g = c;
b = x;
} else if (hp < 4) {
g = x;
b = c;
} else if (hp < 5) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const m = l - c / 2;
return `#${toHexChannel((r + m) * 255)}${toHexChannel((g + m) * 255)}${toHexChannel((b + m) * 255)}`;
};
const terminalSelectionFromAccent = (accent: ParsedHslToken, type: TerminalTheme['type']): ParsedHslToken => ({
...accent,
lightness: type === 'dark'
? Math.max(18, Math.min(32, accent.lightness * 0.55))
: Math.max(72, Math.min(88, accent.lightness + 42)),
});
export const applyCustomAccentToTerminalTheme = (
theme: TerminalTheme,
accentMode: 'theme' | 'custom',
customAccent: string,
): TerminalTheme => {
if (accentMode !== 'custom') return theme;
const accent = parseHslToken(customAccent);
if (!accent) return theme;
return {
...theme,
colors: {
...theme.colors,
cursor: hslToHex(accent),
selection: hslToHex(terminalSelectionFromAccent(accent, theme.type)),
},
};
};
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
@@ -86,4 +175,3 @@ export const clearHostFontWeightOverride = (host: Host): Host => ({
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;

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

@@ -1,3 +1,5 @@
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
/**
* @type {import('electron-builder').Configuration}
*/
@@ -79,7 +81,8 @@ module.exports = {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
extraResources: moshExtraResources('darwin')
},
dmg: {
title: '${productName}',
@@ -105,7 +108,8 @@ module.exports = {
target: 'portable',
arch: ['x64', 'arm64']
}
]
],
extraResources: moshExtraResources('win32')
},
portable: {
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
@@ -125,7 +129,8 @@ module.exports = {
// GNOME launchers or AppImage integrations.
icon: 'public/icon-win.png',
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
category: 'Development',
extraResources: moshExtraResources('linux')
},
deb: {
// Use gzip instead of default xz(lzma) for better compatibility with

View File

@@ -0,0 +1,130 @@
function toNonEmptyString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeConfigOptionValue(value) {
const id = toNonEmptyString(value?.value ?? value?.id);
if (!id) return null;
return {
id,
name: toNonEmptyString(value?.name ?? value?.displayName) || id,
description: toNonEmptyString(value?.description) || undefined,
};
}
function flattenConfigOptionValues(values) {
if (!Array.isArray(values)) return [];
const flattened = [];
for (const value of values) {
const nestedValues = Array.isArray(value?.options)
? value.options
: Array.isArray(value?.items)
? value.items
: Array.isArray(value?.children)
? value.children
: null;
if (nestedValues) {
flattened.push(...flattenConfigOptionValues(nestedValues));
continue;
}
const normalized = normalizeConfigOptionValue(value);
if (normalized) {
flattened.push(normalized);
}
}
return flattened;
}
function findConfigOption(configOptions, category, fallbackIds = []) {
if (!Array.isArray(configOptions)) return null;
return configOptions.find((option) => {
const optionCategory = toNonEmptyString(option?.category);
const optionId = toNonEmptyString(option?.id);
return optionCategory === category || (optionId && fallbackIds.includes(optionId));
}) || null;
}
function normalizeConfigOptionsModels(sessionInfo) {
const configOptions = Array.isArray(sessionInfo?.configOptions)
? sessionInfo.configOptions
: [];
const modelOption = findConfigOption(configOptions, "model", ["model"]);
const reasoningOption = findConfigOption(configOptions, "thought_level", [
"reasoning_effort",
"reasoning",
"thought_level",
]);
const modelValues = flattenConfigOptionValues(modelOption?.options);
if (modelValues.length === 0) return null;
const configuredThinkingLevels = flattenConfigOptionValues(reasoningOption?.options)
.map((option) => option.id);
const availableModelIds = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
.map((modelInfo) => toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id))
.filter(Boolean)
: [];
const availableModelIdSet = new Set(availableModelIds);
const thinkingLevelsByModelId = new Map();
for (const model of modelValues) {
const validThinkingLevels = configuredThinkingLevels.length > 0
? configuredThinkingLevels.filter((level) => availableModelIdSet.has(`${model.id}/${level}`))
: availableModelIds
.filter((modelId) => modelId.startsWith(`${model.id}/`))
.map((modelId) => modelId.slice(model.id.length + 1))
.filter(Boolean);
if (validThinkingLevels.length > 0) {
thinkingLevelsByModelId.set(model.id, validThinkingLevels);
}
}
const currentFromModels = toNonEmptyString(sessionInfo?.models?.currentModelId);
const currentModel = toNonEmptyString(modelOption?.currentValue);
const currentThinking = toNonEmptyString(reasoningOption?.currentValue);
let currentModelId = currentFromModels;
if (currentModel) {
if (currentThinking && availableModelIdSet.has(`${currentModel}/${currentThinking}`)) {
currentModelId = `${currentModel}/${currentThinking}`;
} else if (!currentModelId || (currentModelId !== currentModel && !currentModelId.startsWith(`${currentModel}/`))) {
currentModelId = currentModel;
}
}
return {
currentModelId: currentModelId || null,
models: modelValues.map((model) => {
const modelThinkingLevels = thinkingLevelsByModelId.get(model.id);
return {
...model,
...(modelThinkingLevels ? { thinkingLevels: modelThinkingLevels } : {}),
};
}),
};
}
function normalizeLegacySessionModels(sessionInfo) {
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
return {
currentModelId: toNonEmptyString(sessionInfo?.models?.currentModelId),
models: availableModels.map((modelInfo) => {
const id = toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id);
if (!id) return null;
return {
id,
name: toNonEmptyString(modelInfo?.name ?? modelInfo?.displayName) || id,
description: toNonEmptyString(modelInfo?.description) || undefined,
};
}).filter(Boolean),
};
}
function normalizeAcpSessionModels(sessionInfo) {
return normalizeConfigOptionsModels(sessionInfo) || normalizeLegacySessionModels(sessionInfo);
}
module.exports = {
normalizeAcpSessionModels,
};

View File

@@ -0,0 +1,158 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { normalizeAcpSessionModels } = require("./acpModels.cjs");
test("normalizeAcpSessionModels uses ACP config options for model and reasoning selectors", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.5/xhigh",
availableModels: [
{ modelId: "gpt-5.5/low", name: "GPT 5.5 Low" },
{ modelId: "gpt-5.5/medium", name: "GPT 5.5 Medium" },
{ modelId: "gpt-5.5/high", name: "GPT 5.5 High" },
{ modelId: "gpt-5.5/xhigh", name: "GPT 5.5 Extra High" },
{ modelId: "gpt-5.1-codex-mini/medium", name: "Codex Mini Medium" },
{ modelId: "gpt-5.1-codex-mini/high", name: "Codex Mini High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.5",
options: [
{ value: "gpt-5.5", name: "GPT 5.5" },
{ value: "gpt-5.1-codex-mini", name: "Codex Mini", description: "Fast" },
],
},
{
id: "reasoning_effort",
category: "thought_level",
currentValue: "xhigh",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
{ value: "xhigh", name: "Extra High" },
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.5/xhigh");
assert.deepEqual(result.models, [
{
id: "gpt-5.5",
name: "GPT 5.5",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
},
{
id: "gpt-5.1-codex-mini",
name: "Codex Mini",
description: "Fast",
thinkingLevels: ["medium", "high"],
},
]);
});
test("normalizeAcpSessionModels flattens grouped ACP config option values", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.4/high",
availableModels: [
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.4",
options: [
{
name: "Frontier",
options: [
{ value: "gpt-5.4", name: "GPT 5.4" },
],
},
],
},
{
id: "reasoning_effort",
category: "thought_level",
currentValue: "high",
options: [
{
name: "Reasoning",
options: [
{ value: "low", name: "Low" },
{ value: "high", name: "High" },
],
},
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.4/high");
assert.deepEqual(result.models, [
{
id: "gpt-5.4",
name: "GPT 5.4",
description: undefined,
thinkingLevels: ["high"],
},
]);
});
test("normalizeAcpSessionModels infers thinking levels from available model ids", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "gpt-5.4/high",
availableModels: [
{ modelId: "gpt-5.4/low", name: "GPT 5.4 Low" },
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
],
},
configOptions: [
{
id: "model",
category: "model",
currentValue: "gpt-5.4",
options: [
{ value: "gpt-5.4", name: "GPT 5.4" },
],
},
],
});
assert.equal(result.currentModelId, "gpt-5.4/high");
assert.deepEqual(result.models, [
{
id: "gpt-5.4",
name: "GPT 5.4",
description: undefined,
thinkingLevels: ["low", "high"],
},
]);
});
test("normalizeAcpSessionModels falls back to legacy ACP models when config options are absent", () => {
const result = normalizeAcpSessionModels({
models: {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", displayName: "Opus 4.5" },
{ modelId: "claude-sonnet-4-5", name: "Sonnet 4.5" },
],
},
});
assert.equal(result.currentModelId, "claude-opus-4-5");
assert.deepEqual(result.models, [
{ id: "claude-opus-4-5", name: "Opus 4.5", description: undefined },
{ id: "claude-sonnet-4-5", name: "Sonnet 4.5", description: undefined },
]);
});

View File

@@ -353,16 +353,57 @@ function normalizeCodexIntegrationState(rawOutput) {
// ── Error helpers ──
function safeJsonStringify(value) {
const seen = new WeakSet();
try {
return JSON.stringify(value, (_key, nestedValue) => {
if (typeof nestedValue !== "object" || nestedValue === null) {
return nestedValue;
}
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
return nestedValue;
});
} catch {
return null;
}
}
function stringifyErrorValue(value, seen = new WeakSet()) {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Error) return value.message || value.name || String(value);
if (typeof value !== "object") return String(value);
if (seen.has(value)) return "[Circular error]";
seen.add(value);
const candidates = [
value?.data?.message,
value?.data?.error,
value?.errorText,
value?.message,
value?.error,
value?.cause,
value?.data,
];
for (const candidate of candidates) {
const message = stringifyErrorValue(candidate, seen).trim();
if (message && message !== "{}") {
return message;
}
}
return safeJsonStringify(value) || String(value);
}
function extractCodexError(error) {
const message =
error?.data?.message ||
error?.errorText ||
error?.message ||
error?.error ||
String(error);
const code = error?.data?.code || error?.code;
const message = stringifyErrorValue(error) || "Unknown Codex error";
const code = error?.data?.code || error?.code || error?.error?.code || error?.data?.error?.code;
return {
message: typeof message === "string" ? message : String(message),
message,
code: typeof code === "string" ? code : undefined,
};
}

View File

@@ -0,0 +1,37 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractCodexError } = require("./codexHelpers.cjs");
test("extractCodexError preserves nested error object messages", () => {
const normalized = extractCodexError({
error: {
code: "model_not_found",
message: "Model gpt-test is not available",
},
});
assert.deepEqual(normalized, {
message: "Model gpt-test is not available",
code: "model_not_found",
});
});
test("extractCodexError stringifies unknown object errors instead of [object Object]", () => {
const normalized = extractCodexError({
status: 400,
detail: "Bad request",
});
assert.equal(normalized.message, '{"status":400,"detail":"Bad request"}');
assert.equal(normalized.code, undefined);
});
test("extractCodexError handles circular structured errors", () => {
const error = { status: 500 };
error.self = error;
const normalized = extractCodexError(error);
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
});

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) {
@@ -157,6 +202,15 @@ function toUnpackedAsarPath(filePath) {
return filePath;
}
function isPlausibleCliVersionOutput(value) {
const line = stripAnsi(String(value || "")).trim().split(/\r?\n/)[0]?.trim() || "";
if (!line) return false;
if (/^(?:file|node):\/\//i.test(line)) return false;
if (/^\s*at\s+/i.test(line)) return false;
if (/\b(?:Error|TypeError|ReferenceError|SyntaxError|ERR_[A-Z_]+)\b/.test(line)) return false;
return /(?:^|[^\d])v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?(?:$|[^\d])/.test(line);
}
// ── Shell environment (cached) ──
let _cachedShellEnv = null;
@@ -319,6 +373,8 @@ function serializeStreamChunk(chunk) {
module.exports = {
stripAnsi,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
trackSessionIdlePrompt,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
@@ -327,6 +383,7 @@ module.exports = {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
isPlausibleCliVersionOutput,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,

View File

@@ -0,0 +1,151 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
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("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
assert.equal(isPlausibleCliVersionOutput("file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95"), false);
assert.equal(isPlausibleCliVersionOutput("TypeError: Cannot read properties of undefined"), false);
assert.equal(isPlausibleCliVersionOutput(" at runCli (cli.js:10:1)"), false);
assert.equal(isPlausibleCliVersionOutput("permission denied"), false);
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), 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

@@ -29,7 +29,9 @@ const {
shouldUseShellForCommand,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
isPlausibleCliVersionOutput,
getShellEnv,
getFreshIdlePrompt,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
@@ -53,6 +55,7 @@ const {
getCodexValidationCache,
setCodexValidationCache,
} = require("./ai/codexHelpers.cjs");
const { normalizeAcpSessionModels } = require("./ai/acpModels.cjs");
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
const NETCATTY_TOOL_SKILL_PATH = toUnpackedAsarPath(
@@ -1322,7 +1325,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);
@@ -1427,6 +1430,67 @@ function registerHandlers(ipcMain) {
});
}
function getCommandOutput(result) {
return [result?.stdout, result?.stderr]
.filter((chunk) => typeof chunk === "string" && chunk.length > 0)
.join("\n")
.trim();
}
function getFirstCommandOutputLine(result) {
return getCommandOutput(result).split(/\r?\n/)[0] || "";
}
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env });
return {
launched: true,
exitCode: result.exitCode,
output: getCommandOutput(result),
version: getFirstCommandOutputLine(result),
};
} catch {
return {
launched: false,
exitCode: null,
output: "",
version: "",
};
}
}
function isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) {
return (
command === "codex" &&
usesAcpFallback &&
path.basename(resolvedPath || "").toLowerCase().startsWith("codex-acp")
);
}
function isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
if (!isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) || !probe?.launched) {
return false;
}
const output = String(probe.output || "").toLowerCase();
const hasCodexAcpUsage = /\busage:\s*codex-acp(?:\.exe)?\s+\[options\]/.test(output);
const rejectedVersionFlag =
/(unexpected|unrecognized|unknown)\s+(argument|option|flag)\s+['"]?--version['"]?/.test(output) ||
/['"]?--version['"]?\s+(found|is\s+)?(unexpected|unrecognized|unknown)/.test(output);
return hasCodexAcpUsage && rejectedVersionFlag;
}
function isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe) {
return command === "claude" && usesAcpFallback && probe?.launched && probe.exitCode === 0;
}
function isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
return (
isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) ||
isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe)
);
}
async function runCodexCli(args, options) {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
@@ -1675,11 +1739,17 @@ function registerHandlers(ipcMain) {
// resolveCodexAcpBinaryPath returns a plain string.
let versionCommand = null;
let versionPrependArgs = [];
if (!resolvedPath && agent.resolveAcp) {
let usesAcpFallback = false;
const tryResolveAcpFallback = () => {
if (!agent.resolveAcp) return false;
const result = agent.resolveAcp(shellEnv, electronModule);
if (typeof result === "string") {
if (result && result !== agent.acpCommand && existsSync(result)) {
resolvedPath = result;
versionCommand = null;
versionPrependArgs = [];
usesAcpFallback = true;
return true;
}
} else if (result?.command) {
// On Windows the command may be `node` with the script in prependArgs.
@@ -1689,39 +1759,62 @@ function registerHandlers(ipcMain) {
const displayPath = scriptPath || result.command;
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
resolvedPath = displayPath;
usesAcpFallback = true;
if (scriptPath) {
versionCommand = result.command;
versionPrependArgs = result.prependArgs;
} else {
versionCommand = null;
versionPrependArgs = [];
}
return true;
}
}
return false;
};
if (!resolvedPath) {
tryResolveAcpFallback();
}
if (!resolvedPath || seenPaths.has(resolvedPath)) {
continue;
}
let version = "";
try {
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
const probeCmd = versionCommand || resolvedPath;
const probeArgs = [...versionPrependArgs, "--version"];
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
// --version failed: not a valid CLI executable (e.g. .app bundle)
continue;
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
let version = probe.version;
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
let hasUsableAcpFallback = isAcpFallbackProbeUsable(
agent.command,
usesAcpFallback,
resolvedPath,
probe,
);
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && agent.command === "codex") {
const previousPath = resolvedPath;
if (tryResolveAcpFallback() && resolvedPath !== previousPath && !seenPaths.has(resolvedPath)) {
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
version = probe.version;
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
hasUsableAcpFallback = isAcpFallbackProbeUsable(
agent.command,
usesAcpFallback,
resolvedPath,
probe,
);
}
}
if (!version) continue;
if (!hasPlausibleVersion && !hasUsableAcpFallback) continue;
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
version: hasPlausibleVersion ? version : "Bundled ACP",
available: true,
});
seenPaths.add(resolvedPath);
@@ -1735,6 +1828,50 @@ function registerHandlers(ipcMain) {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const shellEnv = await getShellEnv();
let resolvedPath = null;
let versionCommand = null;
let versionPrependArgs = [];
let usesAcpFallback = false;
const getBundledAcpFallback = () => {
if (command === "codex") {
const acpPath = resolveCodexAcpBinaryPath(shellEnv, electronModule);
if (acpPath && acpPath !== "codex-acp" && existsSync(acpPath)) {
return {
displayPath: acpPath,
command: null,
prependArgs: [],
};
}
return null;
}
if (command === "claude") {
const acpPath = resolveClaudeAcpBinaryPath(shellEnv, electronModule);
const scriptPath = acpPath?.prependArgs?.[0];
const displayPath = scriptPath || acpPath?.command;
if (displayPath && displayPath !== "claude-agent-acp" && existsSync(displayPath)) {
return {
displayPath,
command: scriptPath ? acpPath.command : null,
prependArgs: scriptPath ? acpPath.prependArgs : [],
};
}
}
return null;
};
const resolveBundledAcpFallback = () => {
const fallback = getBundledAcpFallback();
if (!fallback) return false;
if (resolvedPath === fallback.displayPath) {
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
return true;
}
resolvedPath = fallback.displayPath;
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
return true;
};
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
@@ -1744,25 +1881,38 @@ function registerHandlers(ipcMain) {
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
if (!resolvedPath) {
resolveBundledAcpFallback();
} else {
const fallback = getBundledAcpFallback();
if (fallback && resolvedPath === fallback.displayPath) {
versionCommand = fallback.command;
versionPrependArgs = fallback.prependArgs;
usesAcpFallback = true;
}
}
if (!resolvedPath) {
return { path: null, version: null, available: false };
}
let version = "";
try {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
// --version failed: not a valid CLI executable
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
let version = probe.version;
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
let hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && command === "codex") {
if (resolveBundledAcpFallback()) {
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
version = probe.version;
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
}
}
if (!hasPlausibleVersion && !hasUsableAcpFallback) {
return { path: resolvedPath, version: null, available: false };
}
if (!version) {
return { path: resolvedPath, version: null, available: false };
}
return { path: resolvedPath, version, available: true };
return { path: resolvedPath, version: hasPlausibleVersion ? version : "Bundled ACP", available: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
@@ -2268,15 +2418,13 @@ function registerHandlers(ipcMain) {
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
const modelCatalog = normalizeAcpSessionModels(sessionInfo);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
currentModelId: modelCatalog.currentModelId || null,
availableModelIds: modelCatalog.models.map((modelInfo) => modelInfo.id),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
@@ -2284,16 +2432,13 @@ function registerHandlers(ipcMain) {
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
currentModelId: modelCatalog.currentModelId || null,
models: modelCatalog.models,
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
const normalized = extractCodexError(err);
console.error("[ACP] Failed to list models:", normalized.message);
return { ok: false, error: normalized.message };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");

View File

@@ -1,6 +1,9 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
function createIpcMainStub() {
const handlers = new Map();
@@ -27,6 +30,40 @@ function createEmptyStreamResult() {
};
}
function writeFakeCodexAcpUsage(filePath) {
if (process.platform === "win32") {
fs.writeFileSync(
filePath,
"@echo off\r\necho error: unexpected argument '--version' found\r\necho.\r\necho Usage: codex-acp [OPTIONS]\r\nexit /b 2\r\n",
"utf8",
);
return;
}
fs.writeFileSync(
filePath,
"#!/bin/sh\necho \"error: unexpected argument '--version' found\"\necho\necho 'Usage: codex-acp [OPTIONS]'\nexit 2\n",
"utf8",
);
fs.chmodSync(filePath, 0o755);
}
function writeFakeCodexAcpLoaderError(filePath) {
if (process.platform === "win32") {
fs.writeFileSync(
filePath,
"@echo off\r\necho codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file\r\nexit /b 127\r\n",
"utf8",
);
return;
}
fs.writeFileSync(
filePath,
"#!/bin/sh\necho 'codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file'\nexit 127\n",
"utf8",
);
fs.chmodSync(filePath, 0o755);
}
function loadBridgeWithMocks(options = {}) {
const streamCalls = [];
const safeSendCalls = [];
@@ -74,10 +111,23 @@ function loadBridgeWithMocks(options = {}) {
},
"./ai/shellUtils.cjs": {
stripAnsi: (value) => value,
normalizeCliPathForPlatform: (value) => value,
normalizeCliPathForPlatform: (...args) =>
typeof options.normalizeCliPathForPlatform === "function"
? options.normalizeCliPathForPlatform(...args)
: args[0],
shouldUseShellForCommand: () => false,
resolveCliFromPath: () => null,
resolveClaudeAcpBinaryPath: () => null,
isPlausibleCliVersionOutput: (value) =>
typeof options.isPlausibleCliVersionOutput === "function"
? options.isPlausibleCliVersionOutput(value)
: true,
resolveCliFromPath: (...args) =>
typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveClaudeAcpBinaryPath: (...args) =>
typeof options.resolveClaudeAcpBinaryPath === "function"
? options.resolveClaudeAcpBinaryPath(...args)
: null,
getShellEnv: async () => ({}),
invalidateShellEnvCache() {},
serializeStreamChunk: (chunk) => chunk,
@@ -85,7 +135,10 @@ function loadBridgeWithMocks(options = {}) {
},
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
resolveCodexAcpBinaryPath: () => null,
resolveCodexAcpBinaryPath: (...args) =>
typeof options.resolveCodexAcpBinaryPath === "function"
? options.resolveCodexAcpBinaryPath(...args)
: null,
appendCodexLoginOutput() {},
toCodexLoginSessionResponse: () => ({}),
getActiveCodexLoginSession: () => null,
@@ -199,6 +252,582 @@ function loadBridgeWithMocks(options = {}) {
}
}
test("discovers bundled Codex ACP fallback when --version prints usage", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("discovers bundled Codex ACP fallback when PATH Codex shim is broken", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("discovers bundled Codex ACP fallback when PATH Codex exits nonzero", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-exit-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "codex");
assert.equal(agents[0].path, codexAcpPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("does not discover bundled Codex ACP fallback when the fallback cannot run", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-bad-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
fs.mkdirSync(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("does not discover bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-loader-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpLoaderError(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 0);
} finally {
restore();
}
});
test("resolve-cli accepts bundled Codex ACP fallback when --version prints usage", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stored-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
normalizeCliPathForPlatform: () => codexAcpPath,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler(
{ sender: { id: 1 } },
{ command: "codex", customPath: codexAcpPath },
);
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpUsage(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
normalizeCliPathForPlatform: () => null,
resolveCliFromPath: () => null,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler(
{ sender: { id: 1 } },
{ command: "codex", customPath: "/stale/bin/codex" },
);
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when PATH Codex shim is broken", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-broken-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when PATH Codex exits nonzero", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-exit-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
if (process.platform === "win32") {
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
writeFakeCodexAcpUsage(codexAcpPath);
} else {
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
fs.chmodSync(codexPath, 0o755);
writeFakeCodexAcpUsage(codexAcpPath);
}
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli rejects bundled Codex ACP fallback when the fallback cannot run", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-bad-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
fs.mkdirSync(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: null,
available: false,
});
} finally {
restore();
}
});
test("resolve-cli rejects bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-loader-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
writeFakeCodexAcpLoaderError(codexAcpPath);
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: () => false,
resolveCodexAcpBinaryPath: () => codexAcpPath,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexAcpPath,
version: null,
available: false,
});
} finally {
restore();
}
});
test("discovers bundled Claude ACP fallback when the version probe is silent", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-discover-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discoverHandler, "function");
const agents = await discoverHandler({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "claude");
assert.equal(agents[0].path, scriptPath);
assert.equal(agents[0].version, "Bundled ACP");
assert.equal(agents[0].available, true);
} finally {
restore();
}
});
test("resolve-cli accepts stored bundled Claude ACP script path via its launcher", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-stored-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const scriptPath = path.join(tempDir, "index.js");
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
const { bridge, restore } = loadBridgeWithMocks({
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
normalizeCliPathForPlatform: () => scriptPath,
resolveClaudeAcpBinaryPath: () => ({
command: process.execPath,
prependArgs: [scriptPath],
}),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: scriptPath });
assert.deepEqual(result, {
path: scriptPath,
version: "Bundled ACP",
available: true,
});
} finally {
restore();
}
});
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
const ipcMain = createIpcMainStub();

View File

@@ -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

@@ -0,0 +1,344 @@
/**
* Node-side replacement for the upstream Mosh Perl wrapper.
*
* The upstream `mosh` script is a tiny orchestrator: it execs `ssh` to
* run `mosh-server new` on the remote host, scrapes the
* "MOSH CONNECT <port> <key>" line from the SSH stream, then execs
* `mosh-client` locally with that port/key. This module does the same
* thing in JS so we no longer need a Perl interpreter on the user's
* machine — and so we can drive a bundled `mosh-client` even on
* Windows (which has no Perl wrapper).
*
* Flow (driven by terminalBridge.startMoshSession):
* 1. spawn `ssh -t [-p port] [user@]host -- mosh-server new -s ...`
* inside a node-pty, sized to the renderer's cols/rows so password
* / 2FA prompts render natively.
* 2. forward every byte from the ssh PTY to the renderer (parsing
* simultaneously via parseMoshConnect).
* 3. when `MOSH CONNECT <port> <key>` is detected, kill the ssh PTY,
* spawn `mosh-client <ip> <port>` in a fresh node-pty with
* MOSH_KEY=<key> in the environment, and let the bridge swap that
* new PTY into the existing session.
*
* On every supported platform the module relies on the system `ssh`
* binary for the SSH bootstrap (Windows 10 1809+ ships OpenSSH by
* default, macOS / Linux have it everywhere). That keeps key / agent /
* config handling identical to what the user already has working with
* `ssh` — no need to reimplement OpenSSH features in this codebase.
*/
const path = require("node:path");
const net = require("node:net");
const MOSH_CONNECT_RE = /MOSH CONNECT[ \t]+(\d{1,5})[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/;
const MOSH_IP_RE = /MOSH IP[ \t]+(\S+)[ \t]*/;
const PROTOCOL_MARKERS = ["MOSH CONNECT", "MOSH IP"];
function shellQuote(value) {
const text = String(value);
return `'${text.replace(/'/g, `'\\''`)}'`;
}
function validMoshKey(key) {
return key.length === 22 || (key.length === 24 && key.endsWith("=="));
}
function parseConnectLine(line) {
const m = MOSH_CONNECT_RE.exec(line);
if (!m) return null;
const port = Number(m[1]);
const key = m[2];
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
if (!validMoshKey(key)) return null;
return {
port,
key,
matchStartOffset: m.index,
matchEndOffset: m.index + m[0].length,
};
}
function parseMoshIpLine(line) {
const m = MOSH_IP_RE.exec(line);
if (!m) return null;
const host = m[1];
return net.isIP(host) ? host : null;
}
function forEachCompleteLine(text, visit) {
const lineRe = /([^\r\n]*)(\r\n|\r|\n)/g;
let m;
while ((m = lineRe.exec(text)) !== null) {
if (visit({
line: m[1],
newline: m[2],
startIndex: m.index,
endIndex: lineRe.lastIndex,
}) === false) {
break;
}
}
}
function findMoshConnect(text) {
let found = null;
forEachCompleteLine(text, ({ line, newline, startIndex, endIndex }) => {
const parsed = parseConnectLine(line);
if (!parsed) return;
found = {
port: parsed.port,
key: parsed.key,
matchStartIndex: startIndex + parsed.matchStartOffset,
matchEndIndex: endIndex,
visiblePrefix: line.slice(0, parsed.matchStartOffset),
visibleSuffix: line.slice(parsed.matchEndOffset) + newline,
};
return false;
});
return found;
}
function potentialProtocolStart(text) {
if (!text) return -1;
let best = -1;
for (const marker of PROTOCOL_MARKERS) {
const full = text.indexOf(marker);
if (full !== -1) {
best = best === -1 ? full : Math.min(best, full);
}
for (let len = Math.min(marker.length - 1, text.length); len > 0; len -= 1) {
if (marker.startsWith(text.slice(text.length - len))) {
const pos = text.length - len;
best = best === -1 ? pos : Math.min(best, pos);
break;
}
}
}
return best;
}
function buildMoshServerCommand(moshServerPath) {
const trimmed = typeof moshServerPath === "string" ? moshServerPath.trim() : "";
if (!trimmed) return "mosh-server new -s";
return `${shellQuote(trimmed)} new -s`;
}
/**
* Parse a buffer of bytes from the SSH PTY for a MOSH CONNECT line.
*
* Returns { port: number, key: string, matchEndIndex: number } when the
* marker is found, otherwise null. matchEndIndex is the byte offset
* immediately after the matched line in the *current* chunk so callers
* can tell what to strip from the renderer-visible stream (since the
* line is internal protocol, not a user-visible prompt).
*
* The parser is deliberately stateless: callers should keep a small
* trailing window (≤ 4096 bytes) of unmatched data so the marker isn't
* lost when it spans chunk boundaries.
*/
function parseMoshConnect(buffer) {
const text = Buffer.isBuffer(buffer) ? buffer.toString("utf8") : String(buffer);
const found = findMoshConnect(text);
if (!found) return null;
return { port: found.port, key: found.key, matchEndIndex: found.matchEndIndex };
}
/**
* Build the argv for the ssh bootstrap command.
*
* ssh -t [-p port] [user@]host -- LC_ALL=... mosh-server new -s [...]
*
* `-t` allocates a remote TTY so password / 2FA prompts work; `--`
* separates ssh's options from the remote command we want it to run.
* The remote command runs `mosh-server new` and exits, with the magic
* line emitted to stdout.
*
* @param {object} opts
* @param {string} opts.host — hostname or IP
* @param {number} [opts.port] — ssh port (omit for default 22)
* @param {string} [opts.username] — ssh user (defaults to ssh's choice)
* @param {string} [opts.lang] — LC_ALL override for mosh-server
* @param {string} [opts.moshServer]— remote command (default "mosh-server new")
* @param {string[]} [opts.sshArgs] — extra args passed to ssh (e.g. -i path)
* @returns {{ command: string, args: string[] }}
*/
function buildSshHandshakeCommand(opts) {
if (!opts || !opts.host) throw new Error("buildSshHandshakeCommand: host is required");
// No -t / -tt by default: this command only runs `mosh-server new`
// and immediately exits; mosh-server itself doesn't need a TTY for
// the `new` subcommand (it prints MOSH CONNECT to stdout and forks
// into the background). Forcing a TTY would require -tt and break
// BatchMode-friendly stdout capture.
const args = [];
if (opts.port && Number(opts.port) !== 22) {
args.push("-p", String(opts.port));
}
if (Array.isArray(opts.sshArgs)) {
args.push(...opts.sshArgs);
}
const target = opts.username ? `${opts.username}@${opts.host}` : opts.host;
args.push(target);
args.push("--");
// Quote the remote command minimally — ssh runs it through the
// remote shell so simple "command arg arg" works without shell
// metacharacters from us. mosh-server prints the magic CONNECT line
// and otherwise stays silent.
const lang = opts.lang || "en_US.UTF-8";
const moshServer = opts.moshServer || "mosh-server new -s";
args.push(`LC_ALL=${shellQuote(lang)} ${moshServer}`);
return { command: "ssh", args };
}
/**
* Build the argv for the local mosh-client invocation once the
* handshake produced an ip + port + key.
*
* mosh-client <ip> <port> (with MOSH_KEY in env)
*
* `mosh-server` listens on UDP at the IP/port pair it announced. By
* convention, the IP is derived from the "MOSH IP" line emitted before
* MOSH CONNECT, but most servers omit it and the client just uses the
* SSH-resolved hostname / IP. We default to the original hostname when
* no MOSH IP override is available.
*/
function buildMoshClientCommand({ moshClientPath, host, port }) {
if (!moshClientPath) throw new Error("buildMoshClientCommand: moshClientPath is required");
if (!host) throw new Error("buildMoshClientCommand: host is required");
if (!port || port <= 0) throw new Error("buildMoshClientCommand: port must be > 0");
return { command: moshClientPath, args: [host, String(port)] };
}
/**
* Lightweight stream sniffer: hands chunks in, emits MOSH CONNECT
* details + the byte ranges that should be hidden from the user-
* visible stream.
*
* Usage:
* const sniffer = createMoshConnectSniffer();
* for each chunk: const { visible, parsed } = sniffer.feed(chunk);
* send `visible` to renderer; if `parsed`, switch to mosh-client.
*
* Once a parse hits, every subsequent chunk passes through unchanged
* (defensive: the bridge will tear down the SSH PTY immediately after
* the parse so further chunks are unlikely, but we don't want to leak
* partial copies of MOSH CONNECT lines if we somehow get more bytes).
*
* The sniffer keeps a trailing window of unmatched bytes (RING_SIZE) so
* it can detect MOSH CONNECT spanning chunk boundaries.
*/
function createMoshConnectSniffer() {
const RING_SIZE = 4096;
const MAX_PROTOCOL_LINE = 512;
let pending = "";
let parsed = null;
let moshHost = null;
return {
feed(chunk) {
if (parsed) return { visible: chunk, parsed: null };
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
pending += text;
let visibleText = "";
let consumed = 0;
forEachCompleteLine(pending, ({ line, newline, startIndex, endIndex }) => {
if (startIndex > consumed) {
visibleText += pending.slice(consumed, startIndex);
}
const ip = parseMoshIpLine(line);
if (ip) {
moshHost = ip;
consumed = endIndex;
return;
}
const connect = parseConnectLine(line);
if (connect) {
parsed = { port: connect.port, key: connect.key };
if (moshHost) parsed.host = moshHost;
visibleText += line.slice(0, connect.matchStartOffset);
const suffix = line.slice(connect.matchEndOffset);
if (suffix) visibleText += suffix + newline;
consumed = endIndex;
return false;
}
visibleText += line + newline;
consumed = endIndex;
});
if (parsed) {
visibleText += pending.slice(consumed);
pending = "";
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
return { visible, parsed };
}
pending = pending.slice(consumed);
const holdIndex = potentialProtocolStart(pending);
if (holdIndex === -1) {
visibleText += pending;
pending = "";
} else {
visibleText += pending.slice(0, holdIndex);
pending = pending.slice(holdIndex);
if (pending.length > MAX_PROTOCOL_LINE) {
visibleText += pending;
pending = "";
}
}
if (pending.length > RING_SIZE) {
const overflow = pending.length - RING_SIZE;
visibleText += pending.slice(0, overflow);
pending = pending.slice(overflow);
}
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
return { visible, parsed };
},
isParsed() { return parsed !== null; },
};
}
/**
* Assemble the env that `mosh-client` will see. MOSH_KEY is the secret
* shared with mosh-server, and we preserve TERM + LANG so the local
* terminfo lookups pick the right entry.
*/
function buildMoshClientEnv({ baseEnv, key, lang }) {
const env = { ...(baseEnv || {}), MOSH_KEY: key };
if (lang && !env.LANG) env.LANG = lang;
if (!env.TERM) env.TERM = "xterm-256color";
return env;
}
/**
* Resolve the absolute path of the system `ssh` binary. On Windows we
* try the in-box OpenSSH location first because PATH may not list
* it inside the Electron child env.
*/
function resolveSshExecutable({ findExecutable, fileExists, platform = process.platform }) {
const fromPath = findExecutable("ssh");
if (fromPath && fromPath !== "ssh" && fileExists(fromPath)) return fromPath;
if (platform === "win32") {
const sysRoot = process.env.SystemRoot || process.env.SYSTEMROOT || "C:\\Windows";
// Build with the win32-flavored path module so the result is
// back-slash-joined regardless of the host platform we're running
// the lookup from (relevant for cross-platform unit tests).
const inbox = path.win32.join(sysRoot, "System32", "OpenSSH", "ssh.exe");
if (fileExists(inbox)) return inbox;
}
return null;
}
module.exports = {
parseMoshConnect,
buildSshHandshakeCommand,
buildMoshServerCommand,
buildMoshClientCommand,
createMoshConnectSniffer,
buildMoshClientEnv,
resolveSshExecutable,
};

View File

@@ -0,0 +1,229 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseMoshConnect,
buildSshHandshakeCommand,
buildMoshServerCommand,
buildMoshClientCommand,
createMoshConnectSniffer,
buildMoshClientEnv,
resolveSshExecutable,
} = require("./moshHandshake.cjs");
test("parseMoshConnect captures port and key from a typical mosh-server line", () => {
const line = "Welcome\r\nMOSH CONNECT 60001 ABCDEFGHIJKLMNOPQRSTUV==\r\n";
const got = parseMoshConnect(line);
assert.deepEqual(got && { port: got.port, key: got.key }, {
port: 60001,
key: "ABCDEFGHIJKLMNOPQRSTUV==",
});
});
test("parseMoshConnect accepts unpadded base64 keys (length 22)", () => {
const line = "MOSH CONNECT 60005 abcdefghijklmnopqrstuv\n";
const got = parseMoshConnect(line);
assert.equal(got && got.port, 60005);
assert.equal(got && got.key.length, 22);
});
test("parseMoshConnect rejects out-of-range ports", () => {
assert.equal(parseMoshConnect("MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
assert.equal(parseMoshConnect("MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
});
test("parseMoshConnect rejects implausibly short keys (substring noise)", () => {
assert.equal(parseMoshConnect("MOSH CONNECT 60000 abc\n"), null);
});
test("parseMoshConnect handles a Buffer chunk", () => {
const buf = Buffer.from("garbage MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\n");
const got = parseMoshConnect(buf);
assert.equal(got && got.port, 60010);
});
test("buildSshHandshakeCommand omits -t and uses default port", () => {
const got = buildSshHandshakeCommand({ host: "example.com", username: "alice" });
assert.equal(got.command, "ssh");
assert.deepEqual(got.args, [
"alice@example.com",
"--",
"LC_ALL='en_US.UTF-8' mosh-server new -s",
]);
});
test("buildSshHandshakeCommand passes a non-default port via -p", () => {
const got = buildSshHandshakeCommand({ host: "example.com", port: 2222 });
assert.deepEqual(got.args.slice(0, 2), ["-p", "2222"]);
});
test("buildSshHandshakeCommand interpolates lang and moshServer overrides", () => {
const got = buildSshHandshakeCommand({
host: "h",
lang: "zh_CN.UTF-8",
moshServer: "/opt/mosh/bin/mosh-server new -s -c 256",
});
assert.equal(got.args.at(-1), "LC_ALL='zh_CN.UTF-8' /opt/mosh/bin/mosh-server new -s -c 256");
});
test("buildSshHandshakeCommand shell-quotes lang values", () => {
const got = buildSshHandshakeCommand({
host: "h",
lang: "C; touch /tmp/netcatty-owned",
});
assert.equal(got.args.at(-1), "LC_ALL='C; touch /tmp/netcatty-owned' mosh-server new -s");
});
test("buildMoshServerCommand treats custom server input as a path", () => {
assert.equal(
buildMoshServerCommand("/opt/Mosh Tools/mosh-server; touch /tmp/nope"),
"'/opt/Mosh Tools/mosh-server; touch /tmp/nope' new -s",
);
});
test("buildSshHandshakeCommand throws when host is missing", () => {
assert.throws(() => buildSshHandshakeCommand({}), /host is required/);
});
test("buildMoshClientCommand wires moshClientPath, host, port", () => {
const got = buildMoshClientCommand({
moshClientPath: "/usr/local/bin/mosh-client",
host: "10.0.0.1",
port: 60001,
});
assert.equal(got.command, "/usr/local/bin/mosh-client");
assert.deepEqual(got.args, ["10.0.0.1", "60001"]);
});
test("buildMoshClientCommand validates inputs", () => {
assert.throws(() => buildMoshClientCommand({ host: "h", port: 1 }), /moshClientPath/);
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", port: 1 }), /host/);
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", host: "h", port: 0 }), /port/);
});
test("createMoshConnectSniffer detects MOSH CONNECT split across chunks", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("login as: alice\r\nlast login: yesterday\r\nMOSH CONNE");
assert.equal(r1.parsed, null);
assert.ok(!String(r1.visible).includes("MOSH CONNE"));
const r2 = sniffer.feed("CT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.ok(!String(r2.visible).includes("MOSH CONNECT"));
assert.ok(!String(r2.visible).includes("ABCDEFGHIJKLMNOPQRSTUV=="));
});
test("createMoshConnectSniffer does not leak a split MOSH key", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("intro\r\nMOSH CONNECT 60002 ABCDEFGHIJ");
assert.equal(r1.parsed, null);
assert.equal(String(r1.visible), "intro\r\n");
const r2 = sniffer.feed("KLMNOPQRSTUV==\r\n");
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.equal(String(r2.visible), "");
});
test("createMoshConnectSniffer passes through prompts without waiting for a newline", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("password:");
assert.equal(r.parsed, null);
assert.equal(String(r.visible), "password:");
});
test("createMoshConnectSniffer ignores invalid MOSH CONNECT lines", () => {
for (const line of [
"MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
"MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
"MOSH CONNECT 60000 short\r\n",
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n",
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUV==oops\r\n",
]) {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed(line);
assert.equal(r.parsed, null, line);
}
});
test("createMoshConnectSniffer captures MOSH IP without showing protocol lines", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("welcome\r\nMOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==", host: "203.0.113.8" });
assert.equal(String(r.visible), "welcome\r\n");
});
test("createMoshConnectSniffer ignores unsafe MOSH IP values", () => {
const sniffer = createMoshConnectSniffer();
const r = sniffer.feed("MOSH IP --help\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
});
test("createMoshConnectSniffer strips the magic line from visible output", () => {
const sniffer = createMoshConnectSniffer();
const chunk = "shell prompt $ \r\nMOSH CONNECT 60003 ABCDEFGHIJKLMNOPQRSTUV==\r\nbye\r\n";
const { visible, parsed } = sniffer.feed(chunk);
assert.deepEqual(parsed, { port: 60003, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
assert.ok(!String(visible).includes("MOSH CONNECT"), "visible output should not leak the marker");
});
test("createMoshConnectSniffer is idempotent after a parse", () => {
const sniffer = createMoshConnectSniffer();
const r1 = sniffer.feed("MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.ok(r1.parsed);
// Second feed should not re-parse / re-strip — it just passes through.
const r2 = sniffer.feed("trailing bytes after handshake\r\n");
assert.equal(r2.parsed, null);
assert.equal(String(r2.visible), "trailing bytes after handshake\r\n");
});
test("createMoshConnectSniffer trims its ring buffer so old data doesn't accumulate", () => {
const sniffer = createMoshConnectSniffer();
// Feed >> RING_SIZE (4096) bytes of harmless output.
for (let i = 0; i < 10; i += 1) {
const r = sniffer.feed("x".repeat(1024));
assert.equal(r.parsed, null);
}
// Now feed a CONNECT line — ring trimming must not have lost the
// ability to match a fresh marker.
const r = sniffer.feed("MOSH CONNECT 60020 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
assert.equal(r.parsed && r.parsed.port, 60020);
});
test("buildMoshClientEnv injects MOSH_KEY without mutating the input env", () => {
const base = { LANG: "C", PATH: "/x" };
const env = buildMoshClientEnv({ baseEnv: base, key: "deadbeef", lang: "C" });
assert.equal(env.MOSH_KEY, "deadbeef");
assert.equal(env.PATH, "/x");
assert.equal(base.MOSH_KEY, undefined, "input env should not be mutated");
});
test("buildMoshClientEnv defaults TERM when missing", () => {
const env = buildMoshClientEnv({ baseEnv: {}, key: "k", lang: "C" });
assert.equal(env.TERM, "xterm-256color");
});
test("resolveSshExecutable prefers PATH lookups", () => {
const resolved = resolveSshExecutable({
findExecutable: () => "/opt/ssh/bin/ssh",
fileExists: () => true,
platform: "linux",
});
assert.equal(resolved, "/opt/ssh/bin/ssh");
});
test("resolveSshExecutable falls back to in-box OpenSSH on win32", () => {
process.env.SystemRoot = "C:\\Windows";
const resolved = resolveSshExecutable({
findExecutable: () => "ssh", // fakes "not found, returns the bare name"
fileExists: (p) => p.endsWith("OpenSSH\\ssh.exe"),
platform: "win32",
});
assert.equal(resolved, "C:\\Windows\\System32\\OpenSSH\\ssh.exe");
});
test("resolveSshExecutable returns null when nothing is found", () => {
const resolved = resolveSshExecutable({
findExecutable: () => "ssh",
fileExists: () => false,
platform: "linux",
});
assert.equal(resolved, null);
});

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,
};

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