macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
locale name). The system ssh_config has SendEnv LC_*, so the value
leaks to the remote and bash warns "cannot change locale (UTF-8)" on
every login. mosh-server sets its own locale separately, so dropping
LC_* from the spawned ssh's env is the cleanest fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase controls, split CSI/OSC sequences, and ANSI styling without leaking terminal control bytes.
Stream txt/html logs through a persistent renderer and write rendered snapshots directly to the final file, avoiding raw temp files and redundant full rewrites.
Preserve prior log history across clear-screen transitions while coalescing TUI repaint loops to avoid stale frame growth.
Add regression coverage for tmux/zellij-style clears, repeated ED2/ED3 clears, home-clear repaint loops, and shell clear behavior.
* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>
The Google Drive / OneDrive PKCE flow bound a temporary callback server on
a hardcoded 127.0.0.1:45678. If anything on the user's machine already
holds that port (another desktop app, a leftover process, a firewall rule)
the listen fails with EADDRINUSE and the user sees
"Error invoking remote method 'oauth:startCallback': EADDRINUSE".
Split the bridge into a two-step flow so the chosen port is known before
we build the authorization URL:
- oauthBridge.prepareOAuthCallback(): tries the preferred 45678 first,
falls back to an OS-assigned free port (listen(0)) if it's in use, and
returns { port, redirectUri }.
- oauthBridge.awaitOAuthCallback(state): awaits the code on the
already-prepared server.
CloudSyncManager.startProviderAuth now requires the redirectUri to be
passed in; useCloudSync calls prepare → startProviderAuth(redirectUri) →
await, and cancels the prepared server if anything fails before the
browser hop.
windowManager's in-app-popup allow-list reads the active port from
oauthBridge at popup-open time instead of hardcoding 45678, so the
loopback callback keeps working regardless of which port was chosen.
Also: unref() the callback server and closeAllConnections() on teardown
so the OS port is released promptly between flows and test runs don't
leave zombie listeners.
Tests: new electron/bridges/oauthBridge.test.cjs covers the preferred-
port path, the busy-port fallback (#823 regression), the state-mismatch
rejection, the provider-error rejection, the "await without prepare"
guard, and cancel/release semantics. All 85 bridge tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix attached a 32x32 @2x representation to the 16x16 PNG,
which only covers 100% and 200% scale factors. Users on 125/150/175/
250%+ still got a blurry tray icon because Windows had to resample from
one of those two sizes.
Ship a proper multi-size tray-icon.ico (16, 20, 24, 32, 40, 48, 64) and
point the Windows tray loader at it. Windows picks the closest size per
DPI scale on its own, so no addRepresentation / resize juggling is
needed. Linux keeps the existing PNG + @2x path; macOS is unchanged.
Also add scripts/generate-tray-ico.py so the .ico can be regenerated
from public/icon-win.png whenever the source artwork changes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): guard customKeyBindings cross-window sync against echo loop (closes#818)
customKeyBindings was the only synced setting whose two cross-window
handlers (DOM storage event + IPC onSettingsChanged) called
setCustomKeyBindings unconditionally. Every broadcast landed with a
fresh parsed object reference, so React re-rendered and the persist
effect re-broadcast, echoing across windows indefinitely.
While the echoes carry the same content, a rapid second click from
the user can arrive between the outbound broadcast and an older
in-flight echo — the echo's setState then clobbers the latest click
and the UI "bounces" from Disabled back to the original binding.
This matches the report in #818 (disable and reset operations
flicker between values when clicked in quick succession).
Fix: mirror the equality guards used by every other synced field.
Compare the incoming payload (stringified for objects) against the
current value from settingsSnapshotRef, and skip setCustomKeyBindings
when they match. Add customKeyBindings to settingsSnapshotRef so the
IPC handler has access without pulling it into the effect's closure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): stop shortcut sync bounce flicker
* fix(settings): harden shortcut sync ordering
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #763 captured and restored the mouse selection in a keydown-only
microtask. That covers lowercase letters — xterm's _keyDown calls
triggerDataEvent synchronously, so the selection is cleared before the
microtask drains and the restore runs.
Space (keyCode 32) and A–Z (the _keyDown macOS-IME HACK) are instead
routed through the keypress event, which fires in a *later* macrotask.
The keydown microtask drains first, sees the selection still intact, and
no-ops. Then keypress clears it without any restore.
Fix: hook both keydown and keypress in attachCustomKeyEventHandler. The
keypress path gives us a second microtask that drains after _keyPress
has cleared the selection, so the restore actually runs for those keys.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fixed 8% brightness causes compositers to have severe rendering issues. (Only effected on the Midnight color scheme) 10% seems to be okay.
- Reduced backdrop-blur as it's expensive CSS.
- Removed radial-gradient backgrounds (they don't show up)
Closes#813.
#803 enlarged public/icon.svg's squircle to ~88% of the canvas so the
macOS dock icon would match third-party apps that don't leave Apple's
HIG grid margin. That fix is right for macOS — the dock already
rounds / shadows its own icons and the grid margin lines Netcatty up
with neighbors. But every non-mac launcher (Windows taskbar, Start
menu, desktop shortcuts, KDE / GNOME launchers, AppImage integrations)
renders icons full-bleed into a fixed-size slot, so that ~12% padding
shows up as visible empty space around the squircle — the reporter's
"taskbar icon looks smaller and blurrier than other apps".
Split the icon sources by platform:
- public/icon.svg / public/icon.png — unchanged, keeps the #803 88%
fill. mac.icon (implicit via top-level) still uses it.
- public/icon-win.svg — new source with viewBox="100 100 824 824"
(tight-cropped to the squircle) and the faint white outline stroke
disabled. Rendered at 1024×1024 into public/icon-win.png.
- electron-builder.config.cjs wires win.icon and linux.icon to the
new tight-crop source. Top-level icon: stays the padded version so
the mac path is unchanged.
electron-builder generates a multi-size .ico from a ≥256px PNG on
Windows and scales PNG variants for Linux, so a single
1024×1024 source covers both platforms without new build steps.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): sync ghost text to live input on every keystroke
Ghost text was displayed based on whatever input was passed to
GhostTextAddon.show() at fetch time. Between a user's keystroke and
the next debounced fetchSuggestions firing (~100ms), the on-screen
line had already advanced one character but ghost.getGhostText() still
returned the pre-update tail. Pressing → during that window pasted the
stale tail on top of the new char — e.g. type "do", suggestion shows
"cker ls"; type "c", accept immediately → "doc" + "cker ls" lands as
"doccker ls" instead of the expected "docker ls".
Two-layer fix:
1. New GhostTextAddon.adjustToInput(newInput) that re-renders the ghost
against a fresh input without waiting for a new fetch: shrinks /
grows the tail if the suggestion still prefix-matches, hides
otherwise. Called from handleInput after every buffer mutation
(printable, backspace, Ctrl-W, paste tail) when the buffer is
reliable. Unreliable-buffer paths skip the call to avoid making the
ghost lie.
2. Defense-in-depth at both ghost-accept sites (→ and Ctrl-→):
recompute the tail against the live typed buffer instead of trusting
getGhostText's show()-time state. If the suggestion no longer
prefixes the live buffer, hide without writing. Ctrl-→ additionally
resyncs ghost.show() to the live buffer before picking the next word
so getNextWord operates on an up-to-date tail.
* fix(autocomplete): defer ghost text updates to the next xterm render
The previous pass made adjustToInput re-show the ghost synchronously on
every keystroke, but xterm hasn't echoed the triggering char yet at
that moment — cursorX is still the pre-keystroke position. Painting
the shrunken tail there left it visibly overlapping with the char
xterm was about to draw, and the ghost only snapped to the right
column on the next onRender tick. That one-frame overlap is the
"jitter" the reporter still saw.
Switch adjustToInput to a defer-and-reapply pattern:
- On every keystroke that should re-align the ghost, stash the desired
input in pendingInput and hide the element immediately. The
transient blank frame is preferable to an overlap glyph.
- The existing term.onRender listener now checks for a pending update
first: by that tick xterm has processed the echo, cursorX has
advanced, and we can paint the new tail at the correct column via
applyInputUpdate.
- New isActive() exposes "has a live suggestion even if hidden waiting
for render" so a fast "type + →" / "type + Ctrl-→" sequence in the
hide-until-render gap still hits the accept branch and grabs the
recomputed tail from the live buffer.
show() and hide() clear pendingInput so an explicit state change
supersedes any queued adjust.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): restore ghost text, predict-anchor-shift on each keystroke
The previous refactor broke inline completion entirely:
1. useTerminalAutocomplete force-disabled showGhostText whenever
showPopupMenu was on — and both are true by default, so ghost
never rendered.
2. GhostTextAddon put its overlay container *under* xterm's screen
via insertBefore + no z-index. xterm's default renderer paints
theme.background across every cell including empty ones, so the
ghost was fully occluded by the canvas even when the hook *did*
call show().
Fixes both issues and lands the correct per-keystroke strategy the
jitter report was asking for:
- Drop the showGhostText-vs-showPopupMenu gate; respect user settings.
- Put the ghost container back on top of the screen (appendChild +
z-index 1).
- Track anchorInputLength at show() time. adjustToInput now advances
the ghost's left by (newInput.length - anchorInputLength) cells
*synchronously* — i.e. it predicts where xterm's cursor will land
once the echo arrives, instead of re-reading the live cursorX that
hasn't advanced yet. textContent is trimmed in the same call, so
ghost + real-input stay aligned across SSH echo latency with no
one-frame overlap or blank gap.
- Updated GhostTextAddon.test.ts expectations for the new behavior
(and cast the fake-document through unknown to fix the pre-existing
TS error).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): address ghost text review feedback
Follow-ups on the predict-anchor-shift from the previous commit,
based on a code-reviewer pass:
- Backspace / Ctrl-W de-sync: updatePosition's Math.max(0, ...) was
clamping the delta to zero when newInput shrank below the show-time
input length. The ghost then stayed pinned at the original anchor
column while the real cursor walked back left, leaving a gap
between the cursor and the ghost. Let the delta go negative so the
ghost tracks the cursor backwards; clamp the resulting left at 0
instead of clamping the delta.
- Resize staleness: onResize now also resets lastLeft/lastTop and
re-renders, so the dedup cache in updatePosition doesn't hide a
now-stale pixel coordinate after xterm recomputes cell dims.
- Added a regression test for the backspace path covering both the
step-back-below-anchor case and the clamp-at-0-on-overshoot case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): don't accept whole suggestion when buffer is unreliable
Codex flagged (#815 P1 ×2) that the live-buffer recompute on → and
Ctrl-→ falls into a degenerate path when typedBufferReliableRef is
false. My previous cut used live = "" as the fallback, but
fullSuggestion.startsWith("") is always true — so:
- → would write the entire suggestion over whatever is on the line
(post history-recall ↑, Ctrl-R reverse search, etc.).
- Ctrl-→ would reanchor the ghost at the start and getNextWord would
hand back the first token, duplicating leading content on top of
the recalled command.
When the buffer is unreliable, empty buffer ≠ empty line — the line
has content we're not tracking. Fall back to the ghost's own cached
state instead of recomputing:
- → reliable: recompute tail vs live buffer, flip buffer to the
accepted suggestion, reliability back on.
- → unreliable: use ghost.getGhostText() (shown-at-show-time tail)
and don't touch the buffer/reliability flag.
- Ctrl-→ reliable: resync ghost to live, then proceed as before.
- Ctrl-→ unreliable: skip the resync, derive the shrink baseline from
fullSuggestion - current-ghost-tail so the next-word logic still
works off whatever the ghost was actually showing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): hide ghost on single-byte cursor/recall control chars
Reviewer caught that Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E and
friends flip typedBufferReliableRef to false but don't hide the
ghost — leaving it rendering a tail tied to the pre-recall line. The
previous commit's unreliable-→ fallback then reads that stale tail
via ghost.getGhostText() and writes it onto the recalled line,
reproducing the very duplication class the fallback was meant to
prevent (just triggered by Ctrl-P instead of ↑).
Mirror what the escape-sequence branch already does: clearState() +
return. Once the ghost is hidden, ghost.isActive() is false at the →
and Ctrl-→ gates, so the accept-path doesn't fire at all until a
fresh fetchSuggestions re-anchors it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): drop accepted-command cache on cursor/recall keys
Reviewer pointed out that the early returns in the single-byte
ctrl-char and escape-sequence branches leave lastAcceptedCommandRef
untouched. If the user accepts a suggestion via → and then immediately
hits Ctrl-R or ↑ to pick a different command, the fast Enter path
(lines ~611-612) still reads the cached accepted command and records
it — logging the old suggestion instead of whichever command the
reverse-search or history-recall actually ran.
Null lastAcceptedCommandRef at the top of both branches (same place
we hide the ghost and flip reliability off) so accept + recall + Enter
records the recalled command, not the stale accept.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): also null accepted-command cache on Ctrl-C / Ctrl-U
Reviewer flagged this class of bug is still reachable via Ctrl-C /
Ctrl-U. The branch handling those kills the zle line, but the early
return leaves lastAcceptedCommandRef pointing at a command that is
no longer on the line: accept "git status" via → → Ctrl-C to abandon
→ type "ls" → Enter logs "git status" via the fast path instead of
"ls".
Same one-liner as the other early-return branches: null the cache
alongside clearState(). Now the cache's lifetime truly ends at any
event that invalidates the accept.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): null accepted-command cache on bracketed paste too
Fifth-pass reviewer caught the last symmetric gap: the bracketed-paste
branch appends pasted bytes to the buffer but leaves lastAcceptedCommandRef
set. Accept "git status" via → then bracketed-paste " --short" (no
embedded newline), press Enter — the fast path at line 611 still reads
"git status" and logs that instead of "git status --short".
Mirror the non-bracketed paste branch: null the cache before clearState()
returns. All handleInput paths that extend or invalidate the line now
consistently end the cache's lifetime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): predict ghost column by cell width + wrap at EOL
Review caught two geometry bugs in GhostTextAddon.updatePosition that
only surfaced outside the ASCII happy path:
- CJK / fullwidth / emoji glyphs occupy two xterm cells but the
predictor advanced by one char-length per code unit, so ghost
drifted one cell left for every wide char typed and visibly
overlapped the user's glyph.
- When the predicted column crossed term.cols the real cursor wrapped
to the next row, but the predictor just piled more pixels onto
`left` — ghost walked off the right edge instead of following
onto the next line.
Fix both by switching from code-unit count to a small EAW-style
width classifier, then applying row wrapping via
col = (anchorX + cellDelta) % cols
rowOffset = Math.floor((anchorX + cellDelta) / cols)
against the current term.cols. Fake terminal in the test suite now
exposes cols/rows so the unit tests can exercise both invariants:
- "advances the anchor by two cells when a CJK glyph is typed"
- "wraps the ghost to the next row when the predicted column crosses cols"
Known limitation the review already flagged: on backspace-after-wide
we don't have per-grapheme widths to reverse exactly, so the negative
delta falls back to code-unit width on the deleted slice. The slice
is `currentSuggestion[currentInput.length..anchorInputLength]` which
is the same text the user would have typed, so it's correct when
only ASCII edits; wide-char backspace can still drift by one cell.
Fixing this cleanly needs a per-grapheme buffer and is out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): honor showGhostText toggle while a ghost is on screen
Codex flagged (#815 P2) that fetchSuggestions gates new ghost shows
on settingsRef.current.showGhostText, but handleInput's adjustToInput
call had no such guard. A ghost that was already active at the moment
the user turned showGhostText off would keep tracking the typed
buffer via adjustToInput on every keystroke, so the "disabled" setting
only took hold after some unrelated path called clearState().
Two-part fix:
- Add a useEffect watching settings.showGhostText. When it flips false,
hide the active ghost immediately so the disabled setting applies to
whatever was already on screen.
- Gate the adjustToInput call in handleInput behind
settingsRef.current.showGhostText too, so subsequent keystrokes under
the disabled setting don't try to move or re-show a ghost.
Codex's earlier P2 about wrap-at-EOL on line 236 is already resolved
by e61f0e8b (predict-column-with-wrap + CJK width); that comment is
against an older commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): self-heal stale anchor + handle backward-wrap on delete
Codex flagged two real geometry gaps in the predict-anchor-shift math:
1. Stale anchor on high-latency shells. show() captures cursorX from
xterm at debounce-fire time, but under SSH round-trip latency the
user's latest keystroke may not have echoed yet — cursorX is still
the pre-echo column. With updatePosition now purely anchor-based
(no longer reading live cursorX on every render), that stale anchor
becomes frozen; the ghost stays one-plus cells off for the whole
suggestion session until another show() rebuilds it.
2. Backspace crossing a wrapped row boundary. Math.max(0, ...) clamped
targetCol at zero, so deletions past column 0 stayed pinned to the
current row instead of wrapping back to the previous row — exactly
the symmetric case the forward wrap added in e61f0e8b handles.
Fixes:
- Self-heal in updatePosition: while no adjustToInput has moved us
from the show-time baseline (currentInput.length === anchorInputLength),
re-read live cursorX/Y each render tick. Once the user starts typing
the anchor is frozen and delta math takes over.
- Normalize the wrap for negative targetCol: `col = targetCol % cols`
plus `if (col < 0) col += cols`, `rowOffset = Math.floor(targetCol/cols)`
naturally yielding -1 on underflow. Clamp `top` at row 0 so a
runaway negative doesn't render above the terminal.
Two new tests cover both invariants:
- "self-heals a stale anchor on render while no adjustToInput has fired"
- "wraps the ghost to the previous row when deletion crosses a row boundary"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): restore ghost/popup mutual-exclusivity guard in hook
Codex flagged (#815 P2) that dropping the popup-wins-over-ghost
normalization inside useTerminalAutocomplete weakens the hook's own
defensive invariant. The repo enforces mutual exclusivity in two
places already — SettingsTerminalTab toggles one off when the other
turns on, and domain/models.ts normalizes stored settings so
autocompletePopupMenu === true forces autocompleteGhostText to false
— so on the normal Terminal.tsx → store path only one of the two
arrives as true. But the hook's own defaults (DEFAULT_AUTOCOMPLETE_SETTINGS)
have both flags true, and any caller that builds settings directly
from those defaults (tests, future embedders) would end up rendering
popup + inline ghost simultaneously against the repo-wide contract.
Restore the guard, comment it as defensive rather than load-bearing
so future readers don't mistake it for the hiding-invisible-ghost
bug I was fixing last time (that was really the insertBefore /
z-index issue in GhostTextAddon.ts, not this normalization).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): honor typed keystrokes when the prompt parser over-captures
Closes#806.
## Root cause
findPromptBoundary stops at the first "PROMPT_CHAR + space" it sees on
the current line. Themes that render additional content after the
prompt char — most notably oh-my-zsh robbyrussell's "➜ ~ " where "~"
is the cwd — trip it: promptText becomes "➜ ", userInput becomes
"~ sudo id". Every consumer downstream treats the theme's cwd marker
as part of the user's command, so:
1. recordCommand logs entries like "~ sudo id" into history.
2. fuzzyQueryHistory later returns those polluted entries as
suggestions.
3. When the user hits Tab, insertSuggestion compares
suggestion.text ("~ ls") against userInput ("~ lo"), falls into
the Ctrl-U-plus-rewrite path, and the phantom "~ " ends up on
the real command line.
The reporter hit this right after `sudo` because sudo's password
interaction gave history enough polluted entries to start winning
fuzzy matches; without sudo the popup stays empty so the Ctrl-U
rewrite path never fires and the bug is invisible.
## Fix
Track what the user actually typed in an independent keystroke buffer
(typedInputBufferRef) inside the autocomplete hook:
- Append every printable char / paste chunk.
- Pop on backspace, word-kill on Ctrl+W.
- Clear on Enter, Ctrl+C, Ctrl+U, and any escape sequence / unhandled
control char (cursor moves we can't follow invalidate the buffer).
Introduce reconcilePromptWithTypedInput: if detectPrompt's userInput
ends with the typed buffer and is longer, the parser over-captured —
move the excess back to promptText so userInput matches what was
actually typed. Apply at every detectPrompt call site
(fetchSuggestions, the stale-result recheck, insertSuggestion).
For Enter-record the typed buffer wins outright when present, but
only after a live detectPrompt confirms we're at a shell prompt —
otherwise a password-entry Enter would log the password as a
command.
insertSuggestion / ghost-text accept update the typed buffer to the
accepted text so a subsequent Enter records the right command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): track keystroke-buffer reliability, skip it after cursor moves
Codex flagged (#814 P1) that clearing typedInputBufferRef on escape /
control sequences and then re-appending printable keys leaves the
buffer holding only the post-navigation suffix of the real line.
A classic Up-arrow-recall workflow — ↑ to pull "git commit -m fix"
out of history, append one char, Enter — would record just that one
char as the command, polluting history and skewing future fuzzy
matches.
Add typedBufferReliableRef as a companion flag:
- Reset (reliable=true) on Enter / Ctrl-C / Ctrl-U (zle wipes the
line, our buffer is a true view of the empty line again).
- Also reset by insertSuggestion and ghost-text right-arrow accept
once they write the full accepted text and we re-align the buffer
to it.
- Cleared (reliable=false) when any escape sequence, unhandled
control char (Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E / ...)
arrives — those can move the cursor or swap the zle line in ways
an append-only buffer can't follow.
All four call sites now gate on the flag:
- reconcilePromptWithTypedInput receives the buffer only when
reliable, so an unreliable buffer never trims the detector's
userInput (avoids a symmetric flavor of the original bug where
the detector is right and the buffer is wrong).
- Enter-record prefers the buffer only when reliable; otherwise it
falls straight through to detectPrompt.
- The Ctrl+Right (next-word ghost accept) append is skipped when
unreliable so we don't seed the buffer with just that word.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): resync typed buffer when sub-dir select rewrites the line
Codex flagged (#814 P2) that handleSubDirSelect rewrites the command
line via writeToTerminal(Ctrl-U + cmdPrefix + fullPath) but never
touches typedInputBufferRef. After the rewrite the buffer still holds
whatever was typed before, so pressing Enter records that stale partial
input as the executed command — polluting history and steering later
suggestions off course.
Same commit also routes handleSubDirSelect through
reconcilePromptWithTypedInput. The raw detectPrompt would include the
robbyrussell "~ " cwd marker in the command prefix it reconstructs,
which is the original symmetric #806 bug leaking into this path too.
After the rewrite, set the buffer to the newly written command string
and flip reliability back on — the terminal line content now matches
it exactly, so the next Enter-record does the right thing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): reset typed buffer when a paste chunk carries a newline
Codex flagged (#814 P2) that multi-character paste payloads skip the
top-of-handleInput Enter guard (which compares data === "\r" exactly),
so a paste like "cmd\r" goes through the paste branch and the "\r" gets
appended to typedInputBufferRef verbatim. The shell executes "cmd", but
our buffer is left holding "cmd\r...", still marked reliable. The next
Enter then records whatever combined stale string lives there.
Detect line terminators inside multi-char paste chunks: slice from the
last \r or \n onward and keep only that tail as the new buffer content
(and flip reliability back on, since the tail now matches the shell's
zle line). Skip synthesizing recordCommand entries for the flushed
intermediate lines — onCommandExecuted in createXTermRuntime already
tracks pasted multi-line input independently, so duplicating the logic
here would risk double-counting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): clear lastAcceptedCommandRef on paste-with-newline early return
Codex flagged (#814 P2) that the multi-line-paste branch clears the
keystroke buffer and bails out before the rest of handleInput runs —
including the line that resets lastAcceptedCommandRef. If the user had
just accepted a suggestion (Tab / → / popup click), the embedded
newline still flushes it in the shell, but our fast-path cache keeps
holding it. The next Enter then takes the lastAcceptedCommandRef
shortcut and logs that old suggestion as the executed command,
polluting history with something the user didn't actually run.
Null lastAcceptedCommandRef.current at the same point we reset the
typed buffer so the fast path stays aligned with the shell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): require typed buffer to align with live line before recording
Codex flagged (#814 P1) that paste paths which bypass handleInput —
the createXTermRuntime hotkey / context-menu / middle-click handlers
all call writeToSession(...) directly — leave typedInputBufferRef
stale while still marked reliable. A "type prefix → paste remainder →
Enter" flow would then record just the keyboard-typed prefix, feeding
garbage back into autocomplete ranking.
Require alignment: livePrompt.userInput must end with the typed buffer
before we trust it. reconcilePromptWithTypedInput already snaps the two
together when they *are* aligned — if its endsWith check fails, the
buffer is stale (or mid-navigation) and we fall back to
livePrompt.userInput instead. That drops the #806 fix for this one
paste-bypass case, but the same flow would have hit the same pollution
before this PR, so it's a no-regression fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): route out-of-band paste writes through handleInput
Codex flagged (#814 P1) that the reconcile path in fetchSuggestions
has the same stale-buffer failure mode the Enter-record path now
guards against: snippet / keyboard-paste / selection-paste /
middle-click-paste handlers in createXTermRuntime call
writeToSession directly, so typedInputBufferRef only holds whatever
was typed *after* the paste. reconcilePromptWithTypedInput then
treats the pasted prefix as prompt text and trims it, completions
fetch on the truncated input, and accepting a suggestion rewrites
the command incorrectly.
Fix at the source: notify the autocomplete hook with the raw
(pre-bracket-wrap) bytes at every paste site so its keystroke
buffer absorbs them through the same handleInput path keyboard
input uses. handleInput's multi-char paste branch already resets /
aligns the buffer (and invalidates on embedded escape sequences),
so this single extra call per paste site is enough — no new hook
API needed. The existing onData-driven notification at line 684
already covers the non-paste keyboard path, and the snippet /
paste / pasteSelection / middle-click handlers are the only
remaining paths that bypass it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): preserve inner newlines of bracketed-paste input
Codex flagged (#814 P2) that the multi-char-paste branch in
handleInput drops everything before the last newline, but when
bracketed paste is active those newlines are literal input staying on
the zle line — not command terminators. A multi-line paste like
"cmd1\ncmd2" then left only "cmd2" in typedInputBufferRef and the
next Enter recorded / trusted just the tail.
Teach handleInput to recognize the bracketed-paste wrapper
"\x1b[200~...\x1b[201~" and append the enclosed content verbatim
(reliability flag stays on — we know exactly what was added).
Matching change in createXTermRuntime: pass the final (possibly
bracket-wrapped) bytes to ctx.onAutocompleteInput instead of the raw
pre-wrap text so the handle sees the markers when applicable.
Non-bracketed pastes still hit the existing newline-split branch so
each "\n" resets the buffer to the post-terminator tail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(autocomplete): route every prompt consumer through getAlignedPrompt
Each Codex round on #814 surfaced one more code path that needed the
"consume the keystroke buffer only when it's aligned with the live
line" gate: Enter-record, fetchSuggestions (×2), insertSuggestion,
handleSubDirSelect, fetchSubDirForIndex. The fixes were correct but
the guard ended up spelled three different ways across the file:
reconcilePromptWithTypedInput(detectPrompt(term), reliable ? buf : "")
plus a separate `userInput.endsWith(buf)` check in the Enter branch.
That scatter is exactly how the next out-of-band writer gets missed
and regresses #806.
Collapse all six sites onto one helper:
getAlignedPrompt(term, buffer, reliable) → { prompt, alignedTyped }
The helper owns the policy — reliability + endsWith alignment — in one
place. Non-aligned buffers fall through as raw detector output (same
pre-PR behavior, so the worst case for any future forgotten path is
a degrade, not a pollution). Enter-record additionally consumes
alignedTyped, which is only non-null when the buffer truly matches
the tail, so it can record the clean typed command directly without
redoing the endsWith check.
No behavior change from the previous commit; this is purely
deduplication of the alignment guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): inherit reliability on bracketed paste instead of resetting
Codex flagged (#814 P1 follow-up) that the bracketed-paste branch
unconditionally flipped typedBufferReliableRef back to true. A
history-recall-then-paste flow (↑ marks the buffer unreliable, then
bracketed paste arrives) would then set reliable=true even though
the buffer only contains the pasted tail, not the recalled head.
getAlignedPrompt's endsWith check can pass trivially for a short
paste tail that happens to equal the last N chars of the recalled
line, and Enter would record just the pasted fragment.
Reliability is now inherited across a bracketed paste rather than
reset: if the buffer was already aligned, appending the paste keeps
it aligned; if the buffer was unreliable (post-recall / post-cursor-
move), it stays unreliable and the alignment guard in getAlignedPrompt
falls through to the raw detector result the way it should.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal): extend quick encoding switcher to telnet and serial sessions
Closes#804.
TerminalToolbar only showed the UTF-8 / GB18030 encoding menu for SSH
sessions. Telnet and serial sessions had no runtime control — their
decoder was fixed at session start via charsetToNodeEncoding + Node's
StringDecoder, which only knows utf8/latin1/ascii/utf16le. Users
connecting to legacy telnet daemons or MCU consoles emitting GBK were
stuck with the encoding chosen at connect time and could not switch to
read non-latin text correctly.
Main side (terminalBridge.cjs):
- Swap StringDecoder for iconv-lite on the telnet + serial paths so
GB18030 actually decodes. Local PTY and mosh keep StringDecoder —
local follows the OS locale and mosh frames its own UTF-8, neither
needs a runtime swap.
- Store the decoder through a mutable decoderRef on the session object
so the onData closures stay untouched while a new IPC handler can
swap in a fresh decoder mid-session.
- Add normalizeTerminalEncoding that resolves user-facing charset
names (utf-8/gbk/gb2312/gb18030) into iconv identifiers.
- Register netcatty:terminal:setEncoding, which updates the session's
encoding + decoderRef (and mirrors to serialEncoding for aiBridge /
mcpServerBridge exec calls that still read the legacy field).
Renderer + preload:
- preload.setSessionEncoding now tries the SSH handler first and falls
through to the new terminal handler when the SSH side reports ok:
false (non-SSH sessions don't have session.stream). Single preload
method, one extra IPC round-trip only for telnet/serial, which only
happens on explicit user click.
- Drop the isSSHSession gate in TerminalToolbar; replace with
encodingSwitchSupported = not local, not mosh, not localhost-PTY.
- Terminal.tsx onSessionAttached now syncs the initial encoding for
every protocol that supports it (same gate as the toolbar), not
only SSH.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): decode serial exec output with iconv for non-Buffer encodings
Codex flagged (#812 P1) that session.serialEncoding can now be an
iconv-only label like gb18030 after a user switches encoding via the
new terminal toolbar menu. execViaRawPty then called
data.toString(encoding) on the raw Buffer, which throws
"TypeError: Unknown encoding" for anything outside Node's
utf8/latin1/ascii/utf16le set. The throw landed inside the data
listener so Catty Agent / MCP serial exec calls failed and, worse,
the uncaught path could destabilize the process.
Route the decode through a small decodeBufferAs helper: Node encoding
labels still use Buffer.toString for speed; anything else falls back
to iconv-lite (which already handles the toolbar's GB18030). A last-
resort utf8 fallback keeps the listener from throwing even if iconv
itself rejects an unrecognized label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): don't overwrite telnet/serial charset on session attach
Codex flagged (#812 P1) that extending onSessionAttached to sync the
UI encoding for telnet and serial sessions corrupts any host charset
outside the toolbar's two values. terminalEncodingRef is derived from
a useState that only ever resolves to 'utf-8' or 'gb18030', so a host
configured with latin1 / shift_jis had its correct decoder immediately
clobbered with one of those two as soon as the session attached.
SSH is the only protocol that actually needs this sync: its backend
starts in utf-8 regardless of host.charset. startTelnetSession and
startSerialSession already apply options.charset through
normalizeTerminalEncoding, so leaving them alone keeps arbitrary
iconv labels intact; the toolbar's runtime switch remains the path
for users who do want to flip to UTF-8 / GB18030 mid-session.
Restore the SSH-only gate on the sync and document why the new
protocols are intentionally excluded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(terminal): align encoding menu rows with the rest of the popover
The encoding section used a different template from every other row in
the overflow menu: an uppercase "TERMINAL ENCODING" section header,
then two indented rows with a leading check mark instead of a leading
icon. Next to Open SFTP / Scripts / Terminal settings it read as a
different component and made the popover feel disjointed.
Drop the section header and render both encoding options as plain
menuItemClass rows — Languages icon on the left to match the Zap /
Palette leading-icon pattern, label in the flex-1 slot, and the active
row gets a trailing Check in place of a right-side accessory. A single
divider above them still groups the choice visually without the
uppercase label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(terminal): collapse encoding picker into a proper submenu
The previous pass put UTF-8 and GB18030 as flat rows under a separator
inside the main overflow popover. It matched the top rows better but
still looked like a disjoint block of two choices stuck at the bottom.
Turn the encoding picker into a nested submenu so the parent popover
stays a flat list of actions and the choice lives behind a single row
that mirrors the other menu items exactly: Languages icon on the left,
t("terminal.toolbar.encoding") label in the flex slot, the current
value as a muted caption, and a ChevronRight to signal the submenu.
The submenu itself is a second Popover anchored to the right of the
parent. Both popovers are now controlled so picking a value closes
the whole chain in one click, and the parent's onInteractOutside
ignores clicks that land in the submenu portal — otherwise Radix
would treat the submenu click as "outside" the parent and dismiss it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): drop hostname gate, simplify encoding row label
Two issues in one pass:
1. Codex P2 (#812): encodingSwitchSupported still hard-disabled the
menu when host.hostname === 'localhost'. That was a leftover from
when the only "local" escape hatch was hostname-based, but it
incorrectly blocks telnet / SSH sessions aimed at localhost (test
daemons, forwarded endpoints) which do have a real backend decoder
we can drive. The isLocalTerminal / isMoshSession gates already
cover the true local PTY and mosh cases — drop the hostname check.
2. UI: the submenu trigger carried the current value as a muted
caption next to the label. At w-48 the row ran out of room and
truncated "Terminal Encoding" to "Terminal Enc...". Since the
submenu already marks the active choice with a check, the caption
is redundant. Remove it so the full label fits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): stream-decode serial output with a stateful per-command decoder
Codex flagged (#812 P2) that decoding each serial data event with a
stateless decodeBufferAs call corrupts multi-byte characters on
GBK/GB18030 consoles: serial ports deliver chunks at arbitrary byte
boundaries, so the leading half of a 2-byte char in one event gets
emitted as replacement bytes before the trailing half ever arrives.
Build a stateful decoder once per execViaRawPty call (StringDecoder
for Node-native encodings, iconv.getDecoder for iconv-only labels
like gb18030) and feed every chunk through decoder.write(). On
finish, decoder.end() flushes any partial bytes the decoder is still
holding into the final output before it's handed back to the caller.
Strings pass through untouched, same as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): sync SSH encoding on localhost sessions too
Codex flagged (#812 P2) that dropping the 'localhost' check from the
toolbar's encodingSwitchSupported gate left an inconsistency:
Terminal.tsx onSessionAttached still skipped setSessionEncoding when
host.hostname === 'localhost', so a user could pick GB18030, reconnect
a localhost SSH tab, and the backend would restart in utf-8 while the
UI still showed GB18030 — mojibake until manually toggled again.
Drop the hostname clause from the isSSH check here as well. SSH to
localhost is still a real SSH session whose backend starts in utf-8;
the sync is what keeps the UI's picked encoding aligned across
reconnects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): re-sync telnet/serial encoding after user opt-in
Codex flagged (#812 P2) that the SSH-only sync left telnet/serial with
a silent UI/backend mismatch across reconnects: a user picks GB18030,
the tab disconnects and retries, startTelnetSession/startSerialSession
re-apply host.charset, and the UI still shows GB18030 — garbled output
until the user toggles again.
An unconditional sync isn't right either (earlier review: it would
clobber arbitrary host.charset values like latin1 / shift_jis that
the UI's two-value state can't represent). Track whether the user
has actually clicked the toolbar menu this session via
userPickedEncodingRef — once set, any subsequent onSessionAttached
for telnet/serial re-applies the picked value; on first attach with
no user action the backend's configured charset stays intact.
SSH keeps the unconditional sync (its backend always starts in utf-8,
so there's no configured charset to preserve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#805.
The SFTP file-list context menu's Download action only passed the
right-clicked entry to the single-file handler, so selecting N files
and hitting Download still downloaded only one — matching copy/move/
delete, which already iterate selectedFiles, this is the odd one out.
Add onDownloadFiles through the SftpContext → pane callbacks → file-
list chain. In the context menu, if the right-clicked row is part of
pane.selectedFiles and the selection has >1 entry, fall into the new
multi-file path; single selection stays on the existing handler so
its save-dialog UX is unchanged.
The new handleDownloadFilesForSide iterates local selections with the
existing blob path (browser auto-saves each file). For remote panes
it prompts for a target directory once via selectDirectory and streams
every selected file into it — avoids the N-save-dialog prompt storm
that a naive loop would trigger. Mirrors the existing directory-
download branch.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): include legacy HMAC algorithms when legacy toggle is enabled
buildAlgorithms() adds legacy kex, cipher, and host-key algorithms when
the user enables "allow legacy algorithms", but never specified hmac at
all — so ssh2's built-in modern HMAC defaults applied even in legacy
mode. Very old servers (FreeBSD 6.1's OpenSSH circa 2006, per issue #807)
only speak hmac-sha1 / hmac-md5, so MAC negotiation silently settled on
something the server couldn't actually compute. The resulting wrong
exchange-hash MAC then failed host-key signature verification, surfacing
as "Handshake failed: signature verification failed" which misleadingly
looks like a host-key algorithm problem.
Add an explicit algorithms.hmac list in the legacy branch that keeps
modern MACs at the top and appends hmac-sha1 / hmac-md5. Modern servers
will still prefer SHA-2; only servers that literally can't do SHA-2 will
fall back to SHA-1/MD5.
Closes#807.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): skip hmac-md5 when OpenSSL build disables MD5 (FIPS)
Codex flagged (#810 review) that ssh2 validates exact algorithm lists
strictly and FIPS-enabled Node/OpenSSL builds disable MD5. With an
unconditional 'hmac-md5' entry in algorithms.hmac, those builds would
throw "Unsupported algorithm" before the SSH handshake even begins,
turning the legacy toggle into a hard failure even for servers that
only needed hmac-sha1.
Feature-detect MD5 via crypto.getHashes() at module load and only append
'hmac-md5' when it's actually available. hmac-sha1 stays unconditional
— FIPS 140-2 permits HMAC-SHA1 even where SHA-1 is disallowed for other
uses, and ssh2 ships with it in its defaults anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): preserve EtM SHA-1 MAC in legacy algorithm list
Codex flagged (#810 P2) that replacing ssh2's default MAC set with an
exact list omitted 'hmac-sha1-etm@openssh.com', which is present in
ssh2's DEFAULT_MAC. Hosts that only offer EtM SHA-1 MACs would then
fail legacy-mode negotiation with "no matching C->S MAC" even though
they negotiated successfully before the legacy HMAC list was introduced.
Insert 'hmac-sha1-etm@openssh.com' between the SHA-2 EtM entries and
plain hmac-sha1 so modern MACs still take priority and the fallback
chain matches ssh2's own default ordering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Running `eslint .` from the repo root traversed into local git worktrees
under .worktrees/ and linted their source copies, which don't match the
relative ignore patterns like `electron/**` and `scripts/**`. Result: a
thousand no-undef errors from Node/browser globals in worktree-mirrored
.cjs / .mjs files.
Add .worktrees/** to the global ignores list so worktrees are skipped
regardless of whether node_modules is symlinked or fresh-installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): address Codex review feedback on PR #808
Three issues raised on the merged editor-tab-form PR:
P1 — Host-picker switch ignored onDisconnect cancellation
SftpPaneDialogs' onSelectLocal / onSelectHost awaited onDisconnect() and
unconditionally called onConnect() regardless of the dirty-editor prompt
outcome. A user who hit Cancel on the "unsaved changes" dialog would still
end up switched to the new host, stranding the editor tabs on a now-stale
connection. Change onDisconnect to return Promise<boolean> (true when the
disconnect actually ran, false on prompt cancel) and gate onConnect on it.
Propagate the new signature through SftpPaneCallbacks, the pane-actions
hook result, and both left/right implementations.
P2 — setIsQuitting leaked across canceled quits
electron/main.cjs called windowManager.setIsQuitting(true) at the top of
before-quit, before the dirty-editor check returned. If the renderer
reported hasDirty=true and the quit was canceled, isQuitting stayed true,
changing later window-close behavior (close-to-tray paths gated on
!isQuitting would stop firing). Move the setIsQuitting call into a
commitQuit() helper that only runs once we've decided to actually proceed
— on hasDirty=true we leave state untouched.
P2 — SftpSidePanel unmount only cleaned active-pane connections
The cleanup effect inspected only leftPane / rightPane (the active tab
per side), missing editor tabs tied to inactive tabs in the same side
panel. On unmount those tabs would survive with a dead save bridge.
Iterate leftTabs.tabs and rightTabs.tabs and collect every connection id
before calling forceCloseBySessions.
npm test — 212/212 pass, tsc error count unchanged from main, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(editor): stabilize bridge registration effect and memoize filename dedup
Two perf concerns from a focused leak/perf audit of PR #808:
1. Bridge writer effect re-ran on every SFTP state change.
SftpView / SftpSidePanel registered their bridge writer in an effect
with `[sftp]` deps. The `sftp` object identity changes on every SFTP
state update — transfer progress, directory listing, pane updates,
tab switches — so the effect would unregister+reregister constantly
during routine SFTP use. Not a leak (React runs cleanup before each
re-effect), just high-frequency churn on the hot path.
Route through sftpRef and run the effect once; writeTextFileByConnection
is a methodsRef-backed dispatcher that stays valid across sftp re-renders.
2. O(n²) filename disambiguation scan in TopTabs render.
Each editor tab ran `editorTabs.filter(same fileName)` inside the per-tab
render branch. Negligible at ~20 tabs but trivially fixable: build a
fileName→count map in a useMemo keyed on editorTabs and look up in O(1).
Separately noted but NOT fixed here (needs a store refactor and deserves
its own PR): App.tsx subscribing to useEditorTabs() means every keystroke
in an editor tab re-renders the App root. Would need a useEditorTabIds()
selector that only notifies on add/remove.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: ignore local .worktrees/ directory
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): editorTabStore scaffold with single-tab ops
Implements the EditorTabStore class singleton (matching activeTabStore pattern)
with updateContent, markSaved, setWordWrap, setSavingState, close, and subscribe.
Includes useSyncExternalStore hooks and 6 passing unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): editorTabStore promoteFromModal with per-session path dedup
* feat(editor): confirmCloseBySession for session teardown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sftp): writeTextFileByConnection for pane-agnostic saves
Adds a new `writeTextFileByConnection(connectionId, expectedHostId, filePath, content, filenameEncoding?)` method to `useSftpExternalOperations` that looks up the SFTP pane by connection ID (with a hostId safety check) instead of the left/right-side coupling used by `writeTextFile`. Threads the existing `getPaneByConnectionId` callback through the call site and re-exports the new method via `SftpStateApi`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(editor): editorSftpBridge singleton for out-of-React saves
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(editor): extract TextEditorPane from TextEditorModal
Lift Monaco editor body + toolbar + theme sync + paste fallback into a
pure TextEditorPane component. Adds sftp.editor.maximize i18n key to
en.ts and zh-CN.ts locale files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(editor): drop unused getLanguageId import in TextEditorPane
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(editor): TextEditorModal delegates to TextEditorPane
Replace the monolithic modal (560 lines including full Monaco setup)
with a thin Dialog shell (~150 lines) that owns content/saving/saveError/
languageId state, save orchestration, and dirty-check on close, then
delegates all editor chrome to <TextEditorPane chrome="modal" />.
Exports TextEditorModalSnapshot for the optional onPromoteToTab callback
so callers can later wire tab promotion (Task 12) without breaking the
existing interface — the new prop is optional and existing callers
(SftpOverlays.tsx) are source-compatible with zero changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(editor): include fileName and wordWrap in TextEditorModalSnapshot
Task 12 will populate the promoted tab with these fields, so the snapshot
must carry them from the modal at maximize time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): UnsavedChangesDialog three-button confirm
* fix(editor): resolve UnsavedChangesDialog re-entrance and unmount leaks
- Re-entrance: if prompt() is called while a prior prompt is still pending,
cancel the prior one so its caller doesn't hang forever.
- Unmount: resolve any in-flight prompt as "cancel" in the effect cleanup
so awaiters don't leak when the provider unmounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): TextEditorTabView tab-form shell
Add TextEditorTabView component that binds an editorTabStore entry to
TextEditorPane, with CSS display:none toggling for inactive tabs so the
Monaco instance persists across tab switches. Also adds setLanguage
public method to EditorTabStore (lands Task 15's intent early — Task 15
can be a no-op).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(editor): read live store state in TextEditorTabView handlers
React state snapshot lags the store by a microtask. Closing over `tab`
meant a keystroke between Monaco's onChange and a Ctrl+S would write
stale content and mark a stale baseline. Read via editorTabStore.getTab
at call time instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): dispatch editor:* tab ids in App and activeTabStore
- Add EDITOR_PREFIX, isEditorTabId, toEditorTabId, fromEditorTabId helpers
- Add useIsEditorTabActive hook to activeTabStore
- Update useIsTerminalLayerVisible to exclude editor tabs
- Import useEditorTabs and TextEditorTabView into App.tsx
- Append editor tab ids (editor:<id>) to allTabs in hotkey handler
- Mount TextEditorTabView per editorTab with CSS visibility toggling
- Add editorTabs to executeHotkeyAction useCallback dependency array
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(editor): render editor tabs in TopTabs with icon/dirty/tooltip
- Add `fromEditorTabId`, `isEditorTabId` imports to TopTabs.tsx
- Add `FileCode`, `FileText` icons; use FileCode for code-like extensions
- Extend `TopTabsProps` with `editorTabs`, `onRequestCloseEditorTab`, `hostById`
- Build `editorTabMap` for O(1) lookup; add `editor` branch in `orderedTabItems`
- Render editor tab chrome matching terminal tab style: file icon, dirty dot (●),
filename with disambiguation suffix for duplicate filenames, close button
- In App.tsx: add stub `handleRequestCloseEditorTab`, `orderedTabsWithEditors`,
pass new props to `<TopTabs>`
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(editor): hoist editor-tab code-extension regex and use onSelectTab
- Move CODE_EXTENSIONS_RE to module scope so it isn't recompiled per render.
- Call onSelectTab(tabId) for consistency with other tab types, instead of
reaching into activeTabStore directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): maximize modal to tab and dirty-confirm tab close
Wire onPromoteToTab from TextEditorModal through SftpOverlays and
useSftpViewFileOps so clicking the maximize button snapshots editor
state into editorTabStore and activates the new editor tab.
Replace the stub handleRequestCloseEditorTab in App.tsx with a real
dirty-confirm flow using UnsavedChangesProvider render-prop: clean tabs
close immediately, dirty tabs prompt save/discard/cancel, and save
routes through editorSftpBridge with markSaved on success.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(editor): register SFTP bridge and gate session close on dirty editor tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(editor): make onDisconnect async so host-picker waits for dirty check
The session-close dirty gate added in Task 13 made onDisconnect async, but
the host-picker in SftpPaneDialogs still called it synchronously before
kicking off onConnect — a fire-and-forget that raced past the dirty prompt
and let unsaved editor tabs slip through. Propagate the Promise return type
through SftpPaneCallbacks / SftpPaneDialogs / useSftpViewPaneActionsResult
and await it at the host-picker call sites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): block app quit while editor tabs are dirty
Add a before-quit IPC guard that asks the renderer whether any editor
tab has unsaved changes. If dirty tabs exist, preventDefault() blocks
the quit and a warning toast is shown. The app quits normally once
editors are clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(editor): add 5s timeout fallback to quit-guard IPC check
If the renderer crashes or throws before reporting back, the quitGuard
would stay busy forever and the app could not be quit. Fall back to
force-quit after 5 s if no reply arrives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): quit-guard uses quitConfirmed flag to prevent re-entry loop
The prior flow reset quitGuardChannelBusy before calling app.quit(), which
on macOS re-fires before-quit and re-entered the dirty check with the flag
cleared — creating an infinite IPC loop. Introduce a separate quitConfirmed
flag that commits to quitting before app.quit() fires, so the re-entry takes
the fast path.
Also extract QUIT_GUARD_TIMEOUT_MS and clarify that a concurrent quit while
a check is in flight is swallowed (preventDefault) rather than letting the
second event through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): use absolute inset-0 for tab panel and add sr-only DialogTitle
Two bugs surfaced during the first dev-server smoke test:
1. Editor tab content was blank because TextEditorTabView used only
className="h-full", while its sibling panels (VaultView, SftpView,
TerminalLayerMount, LogView) all fill their flex-1 parent via
`absolute inset-0`. In normal flow the editor tab collapsed to zero
height. Match the sibling convention.
2. Radix printed an accessibility warning because the Task 7 refactor
pulled the DialogTitle out of DialogContent and into the Pane header
(now a plain span). Add a visually hidden DialogTitle that mirrors the
filename, so screen readers have a title without showing it twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): raise tab panel z-index to 20 so it sits above TerminalLayer
TerminalLayer's root is visibility:hidden when the active tab is an editor
tab, but its inner panels set `absolute inset-0 z-10` on their own and those
still paint. Without an explicit z on the editor tab panel, TerminalLayer's
inner bg-background div was covering the Monaco content, producing a blank
screen.
Also add bg-background to the wrapper so the editor tab paints an opaque
surface (matches the pattern VaultViewContainer / TerminalLayer follow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): show host label and remote path next to filename in tab header
The editor tab form previously only showed the bare filename in its header,
which is ambiguous when the same filename is open against multiple hosts.
Add an optional subtitle prop on TextEditorPane and populate it from the
tab form with `<hostLabel>:<remotePath>` rendered in muted text beside the
filename. The modal keeps its existing filename-only header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): bridge supports multiple useSftpState instances
useSftpState is instantiated in both the top-level SftpView and the
terminal's SftpSidePanel, each owning its own pane registry. The editor
bridge previously stored only one writer, so maximizing a file opened from
the terminal side panel registered nothing (bridge was owned by SftpView
which may never have mounted) and save failed with "bridge not registered".
Change the bridge to track a Set of writers and dispatch by trying each
until one owns the connectionId (signalled by its specific "connection no
longer available" error). Add registerEditorSftpWriterScoped that returns
an unregister fn so each instance's cleanup removes only its own entry.
Register in both SftpView and SftpSidePanel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): Cmd+W closes editor tab + terminal close forces tab close
Two behaviors added after user feedback from dev-server smoke-test:
1. Cmd/Ctrl+W (the closeTab hotkey) previously did nothing on editor tabs
because executeHotkeyAction had no branch for editor:* ids. Add one that
reaches into the UnsavedChangesProvider render-prop's close flow via a
ref, routing through the existing dirty-confirm path.
2. Closing a terminal tab unmounts its SftpSidePanel which destroys the
useSftpState instance that owned the connection. Any editor tab promoted
from that panel would then be stuck — bridge gone, save channel dead.
On SftpSidePanel unmount, gather the connection ids it owned and call a
new editorTabStore.forceCloseBySessions to drop matching editor tabs.
Dirty state is dropped because the user closed the terminal knowing the
file was open — there is no save channel left anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): Cmd/Ctrl+W works when focus is inside Monaco
Monaco's internal key-event dispatcher swallows keydown before the
capture-phase handler on the Pane's root div can see it, so the global
hotkey dispatcher never got the chance to close the editor tab when the
editor had focus. Register a Monaco editor command for the close-tab
keybinding and route it through a handleCloseRef — mirrors the same
pattern used for Cmd/Ctrl+S. Also drop the modal-only guard in the
capture-phase handler so the outer-chrome path works in tab mode too.
TextEditorTabView now receives an onRequestClose(tabId) prop that App.tsx
wires via the render-prop-exposed handleRequestCloseEditorTabRef, same
mechanism as the hotkey-dispatcher path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): fall back to Vaults when forceCloseBySessions removes the active tab
Closing a terminal tab triggers SftpSidePanel unmount which force-closes its
editor tabs. If the editor tab being removed happened to be the active tab
(user maximized → then closed the owning terminal from another path), the
app ended up on a stale activeTabId with no selected tab and blank content.
Inside forceCloseBySessions, if the active tab was one of the removed
editor ids, redirect to 'vault'. Picking a more sophisticated neighbor
would need the full orderedTabs list which isn't reachable from this layer;
Vaults is always valid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Enlarge app icon squircle so it matches other macOS dock apps
public/icon.png was generated from logo.svg which keeps the Apple HIG
grid margin (~100px all around the 824x824 squircle in a 1024 canvas).
Most third-party macOS apps (WeChat, Office, Messages, etc.) enlarge
their squircle to fill ~90% of the canvas, so Netcatty's icon looks
visibly smaller than its neighbors in the dock.
Introduce public/icon.svg as a dedicated app-icon source that tightens
the viewBox to 68 68 888 888 so the squircle renders at ~93% fill, then
regenerate public/icon.png from it. logo.svg stays untouched since it
is shared with the splash screen and tray template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Dial back icon squircle fill from 93% to 88%
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snippet rows used a padding-based offset to account for the chevron
column in package rows, but the flex gap between chevron and icon
wasn't being compensated so the FileCode icon sat 4-6px to the left of
the Package icon above it. Mirror the package row's flex layout
literally by rendering an invisible chevron placeholder, so both row
types share the same column structure.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Render snippets sidebar as an expandable tree (#800)
The terminal sidebar used breadcrumb navigation, so switching between
packages meant clicking out and back in. Replace that with a single
tree view where each package row has a chevron to expand/collapse
(SFTP-style), so snippets across multiple packages stay visible and
reachable without drilling.
- All discovered packages default to expanded, so the tree matches the
user's expectation of seeing everything at once.
- Search flattens to a list of matching snippets regardless of nesting,
each annotated with its package path so the origin is still clear.
- Implicit ancestor packages (e.g. "a/b/c" implies "a" and "a/b") are
materialized so deeply nested snippets aren't orphaned when a parent
package isn't explicitly listed.
- Depth-based left padding + chevron rotation mirror the SFTP tree
view's affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Unify snippet row typography with tree + move command to tooltip
Snippet rows were rendered as two-line blocks (label + inline command
preview), which made them visually taller and heavier than the
single-line package rows in the tree, and long commands overflowed the
container. Collapse them to single-line rows that match the package row
layout exactly (same text size, same padding, aligned icon column) and
surface the full label + command text in a tooltip on hover.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Preserve collapsed packages across snippet refreshes (codex)
The auto-expand effect compared prev.size to normalizedPackages.size to
decide whether to repopulate, but collapsed rows shrink prev.size, so any
later snippet/package change would trip the condition and overwrite the
user's collapse state with a bulk re-expand.
Track the set of packages ever observed in a ref and only auto-expand
paths that are new since the previous render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous template icon was a tiny solid silhouette that didn't fill
the menu bar slot. Rebuild it by extracting the cat head, ears, paws,
squinty eyes and nose/mouth paths directly from public/logo.svg so the
tray icon matches the app icon character, then tighten the viewBox so
the cat fills the canvas.
Windows/Linux tray-icon.png is unchanged.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tray icon was force-resized to 16x16 on all non-macOS platforms, so
Windows had to upscale it at every DPI scale above 100%. Attach the
existing @2x asset as a HiDPI representation instead and let the OS pick
the right pixel size per scale factor.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bulk-action bar for multi-select (selected count, Select All /
Deselect All / Delete / close) was rendered inside the Hosts
section, so it scrolled out of view as soon as the user moved
past the first row of cards.
Hoist the bar out of the scroll container and render it as a
sibling right after the top header. It is now always visible below
the header while multi-select is active in the Hosts section, and
slims down visually:
- Single flat row (no inner pill, no secondary border)
- Compact button sizing: h-7, px-2, text-xs, icon-12
- Bottom-only border for separation from the scroll area
- Count label forced to h-7 + leading-none so it vertically
centers against the buttons
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse four terminal toolbar actions behind a "More" popover
The terminal status-bar toolbar had seven visible icon buttons
(SFTP, Encoding, Scripts, Theme, Highlight, Compose, Search) plus
the close button. That's a lot of icons for a toolbar that sits
right above the terminal output — it reads as cluttered and pushes
the connection info / host name around on narrow tabs.
Fold the four "opener" actions — SFTP, Encoding, Scripts, Terminal
Settings — behind a single `MoreHorizontal` (⋮) popover. The three
mid-session toggles (Highlight, Compose, Search) stay in the bar
because they're used repeatedly during a session.
- components/terminal/TerminalToolbar.tsx:
* Add MoreHorizontal import, a shared `menuItemClass` style for
popover rows.
* Replace the four inline Buttons with a single Popover whose
content lists each action as an icon + label row.
* Inline the Encoding sub-popover into the same menu: a
Languages-icon section header followed by two `Check`-marked
radio-like rows for UTF-8 / GB18030 — still only rendered when
`isSSHSession && onSetTerminalEncoding`.
* SFTP row respects the existing connected-state: disabled +
50% opacity until the session is connected, and label falls back
to "availableAfterConnect".
- application/i18n/locales/en.ts, zh-CN.ts:
* New `terminal.toolbar.more` key — "More actions" / "更多操作"
— used as the ⋮ button's aria-label and tooltip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Move terminal overflow menu to end and use vertical dots
The ⋮ overflow trigger was the first icon in the toolbar with a
horizontal-dots glyph. Visually it read as the primary action and
competed with the mid-session toggles next to it.
Move the Popover to the end of the toolbar (just before the close
X when shown), switch the icon to MoreVertical, and flip the
popover alignment to `end` so it opens leftward from the right
edge.
Toolbar order is now: Highlight → Compose → Search → ⋮ → (X).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add terminals to workspace + New Workspace from QuickSwitcher
Two entry points share a single multi-select picker that lets the
user add Local Terminal + any combination of hosts into a workspace:
1. Focus-mode sidebar "+" button appends the selected targets to the
active workspace as new panes.
2. QuickSwitcher "New Workspace" button (small inline action next to
the Jump To hint) spins up a brand-new workspace tab populated
with the selected targets.
## Changes
### domain/workspace.ts
- pruneWorkspaceNode now rebalances surviving siblings to EQUAL
sizes after removal, instead of re-normalising the prior skew.
Matches the "auto-redistribute on close" expectation.
- New appendPaneToWorkspaceRoot(root, sessionId, direction='vertical'):
if root already splits in the requested direction, pushes the new
pane onto its children and resets sizes to equal; otherwise wraps
root + new pane in a new 0.5/0.5 split. Flattens long chains of
appends instead of producing degenerate nested trees.
### application/state/useSessionState.ts
- appendHostToWorkspace(workspaceId, host, direction?) — atomic
"build a session for this host and append it to the root", keeps
activeTab on the workspace and focuses the new pane.
- appendLocalTerminalToWorkspace(workspaceId, options?, direction?)
— mirror of the above for local shells.
- createWorkspaceFromTargets(targets, name?) — accepts a mixed list
of {kind:'local',...} / {kind:'host',host} and creates a new
workspace with one pane per target. Defaults viewMode to 'focus'
so the QuickSwitcher flow lands in the sidebar layout.
- All three exported from the hook.
### components/workspace/AddToWorkspaceDialog.tsx (new)
QuickSwitcher-styled multi-select picker:
- Fixed top-center overlay, same chrome as QuickSwitcher (border,
shadow, rounded-xl, borderless search input, bg-primary/15 cursor).
- Two sections: Local Shells (currently just Local Terminal) and
Hosts. Hover follows keyboard cursor.
- Toggle rows with click or Space / Enter; ⌘/Ctrl+Enter submits;
Esc closes. Right-side Check marks visible items.
- Thin footer bar with Cancel + "Add N" button.
### App.tsx
- Root-mounted single instance of AddToWorkspaceDialog with a
discriminated-union state:
{ mode: 'append'; workspaceId } | { mode: 'create' } | null.
- onAdd dispatches based on mode — append loops through the picker
targets calling the two append helpers; create calls
createWorkspaceFromTargets once.
- TerminalLayer's focus "+" now sends an onRequestAddToWorkspace
(workspaceId) up to App instead of owning its own dialog.
- QuickSwitcher's onCreateWorkspace callback repurposed to open the
dialog in create mode (replaces the older CreateWorkspaceDialog
route for this specific flow).
### components/TerminalLayer.tsx
- Dropped the inline AddToWorkspaceDialog + addHostPanelOpen state;
replaced the two append callbacks with a single
onRequestAddToWorkspace prop wired to the "+" button.
- Focus-sidebar header: replaced the "Terminals · N" counter with an
immersive borderless search input (bg-transparent, shadow-none,
termFg color) for filtering the terminal list; "+" and Columns2
buttons moved to the right.
- Session list filtered client-side by the search term across
hostLabel / hostname / username.
### components/QuickSwitcher.tsx
- Re-introduced onCreateWorkspace prop (was removed as unused).
- "New Workspace" inline button (Plus icon + label) sits on the
right of the Jump To hint row: border, rounded, hover bg. Click
fires onCreateWorkspace then closes QS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add configurable New Workspace shortcut
Mirrors QuickSwitcher's "+ New Workspace" button via a keyboard
binding so the dialog can open in one keystroke without passing
through QS.
- domain/models.ts: new DEFAULT_KEY_BINDINGS entry id=new-workspace,
action=newWorkspace, default ⌘+Shift+J (Mac) / Ctrl+Shift+J (PC).
Audited the defaults — only quick-switch uses J (⌘+J), so the
shifted combo is free. The binding sits in the 'app' category so
it shows up in Settings → Shortcuts and can be rebound by the user.
- application/state/useGlobalHotkeys.ts: wire newWorkspace into the
HotkeyActions interface, getAppLevelActions() allowlist, and the
global keydown switch so the scheme-driven handler dispatches it.
- App.tsx: handle case 'newWorkspace' inside executeHotkeyAction by
calling setAddToWorkspaceDialog({ mode: 'create' }) — same entry
as QuickSwitcher's button, just without having to open QS first.
- application/i18n/locales/zh-CN.ts: add '新建工作区' translation for
settings.shortcuts.binding.new-workspace. English falls back to
the KeyBinding.label field ("New Workspace"), so no en.ts change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1: don't check setState flag after the updater returns
Codex flagged that appendHostToWorkspace / appendLocalTerminalToWorkspace
were racy: both flipped an `inserted` flag inside setWorkspaces'
updater and then read it synchronously to decide whether to commit
the matching session via setSessions. React does NOT guarantee
updaters run synchronously (concurrent rendering, StrictMode
double-invoke, etc.), so the flag could still be false at the read
site even though the workspace exists. In that case setSessions was
skipped while the queued workspace update could still insert a new
pane referencing newSessionId — leaving a pane with no backing
session in state.
Fix: add a workspacesRef kept in sync with the workspaces state on
every render, and perform the existence check synchronously *before*
queuing any setState. Once we've confirmed the workspace exists on
the latest committed state, both setWorkspaces and setSessions are
called unconditionally, so they can never diverge.
The ref approach also correctly handles the multi-target append
loop path — React batches the updaters and applies them in sequence,
so sibling pane/session writes land in matching order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1+P2: narrow prune rebalance; append in root direction
### P1 — pruneWorkspaceNode over-rebalanced ancestor splits
The equal-sizes rebalance was unconditional during the recursive
walk, so closing a pane deep in one branch also rewrote unrelated
ancestor ratios (e.g., a root 0.8/0.2 vertical split got normalised
to 0.5/0.5 when a grand-child horizontal pane closed).
Now each split level tracks whether it actually lost a DIRECT
child. Only splits where a direct child disappeared get their
siblings reset to equal sizes. Ancestors whose direct children all
survived keep their original ratios (defensively re-normalised in
case a descendant subtree collapsed shape).
### P2 — Append path ignored the root's current direction
onAdd in App.tsx called the two append helpers without a direction,
so both defaulted to 'vertical'. appendPaneToWorkspaceRoot only
flattens into the root split when the directions match; if the
workspace root was horizontal (e.g., user split top/bottom earlier),
each append wrapped the entire existing tree into one side of a new
vertical split — existing panes crammed into one branch, new pane
hoarding half the space.
Read the current root direction out of the target workspace and
pass it down so new panes become peers of the existing root
siblings regardless of horizontal vs vertical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: allow serial hosts in create-workspace picker
The picker used to filter out every host with protocol='serial'
regardless of mode. That was correct for append mode (the
appendHostToWorkspace helper has no serial path and early-returns)
but a regression for create mode — the old createWorkspaceWithHosts
flow passed serial hosts through and createWorkspaceFromTargets
still builds a SerialConfig-backed session for them, so there was
no reason to block them in the "+ New Workspace" entry.
Move the filter from the dialog up to App.tsx:
- AddToWorkspaceDialog drops the serial filter; selectableHosts is
simply the hosts prop.
- App.tsx passes `hosts.filter(h => h.protocol !== 'serial')` when
mode is 'append', and the full list when mode is 'create'.
Result: users can once again build a workspace from serial hosts
via QuickSwitcher's "+ New Workspace" button or the ⌘/Ctrl+Shift+J
hotkey, while append-to-existing keeps its earlier safe behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: don't commit session when append target disappears
Follow-up to the earlier ref-based guard. The ref check eliminates
the common "workspace already gone" case but still leaves a small
race: if closeWorkspace runs between the ref read and setWorkspaces'
updater firing, prev.map returns the unchanged workspaces but
setSessions / setActiveTabId still execute — leaving an orphan
session whose workspaceId points at a deleted workspace and jumping
activeTabId to a closed tab.
Nest setSessions + setActiveTabId inside the setWorkspaces updater
so the writes are gated on the same authoritative match used for
the tree update. The setSessions updater also de-dupes by newSessionId
so React 18 StrictMode's dev-time double-invoke of the outer updater
doesn't append the same row twice. Same pattern applied to
appendLocalTerminalToWorkspace.
The existing closeSession already uses the nested-setState shape, so
this matches the codebase convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings > Application used `text-3xl font-semibold` on
`{appInfo.name}`, which resolved to lowercase "netcatty" (from
electron's app.getName() / package.json). The Vault sidebar already
renders the brand as `text-xl font-black italic tracking-tight`
with mixed-case "Netcatty", so the two brand surfaces didn't
match — same logo, different wordmark weights and capitalization.
Use the Vault's italic/heavy treatment in Settings too (keeping
the hero text-3xl size) and hardcode "Netcatty" mixed-case so the
wordmark is consistent everywhere the app presents its identity.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Polish workspace focus-mode sidebar
- Decouple from side panel position: replace flex-row-reverse on the
outer row with order-last on the side panel itself, so the workspace
focus-mode sidebar and terminal area stay in source order (sidebar
on the left) regardless of whether the terminal side panel is
pinned left or right.
- Make the sidebar width user-resizable. New storage key
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH with a useStoredNumber
default of 224px (matches the old w-56), clamped 160..480. Drag
handle sits on the right edge using the same pattern as the side
panel; rAF-throttled mousemove, persisted on mouseup.
- Paint the sidebar with resolvedPreviewTheme.colors.background /
.foreground so it reads as one continuous surface with the focused
terminal's output area instead of a distinct tinted panel. The
border-r is kept as a thin separator from the terminal column.
- Session rows swapped from <div> to RippleButton to match the Vault
sidebar's click ripple feel, and restyled to avoid the old
primary-tinted selection:
* selected: bg-foreground/10 text-foreground (soft neutral over
the terminal-theme sidebar bg)
* unselected: bg-transparent text-foreground/75
* font weight upgrades to semibold on selected; font-size is fixed
* hover:text-inherit pins text color on hover so the ghost
variant's hover:text-accent-foreground doesn't flip the title
color when the cursor passes over a row
- Drop the former `border border-primary/30` selection outline and
the primary-tinted row bg entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1: use terminal-theme colors for focus sidebar rows
Codex flagged that the session rows were mixing two theme systems:
the sidebar now paints with resolvedPreviewTheme (terminal theme),
but row classes like bg-foreground/10, text-foreground, and
hover:bg-foreground/15 resolve against the app theme CSS vars. With
followAppTerminalTheme off and app/terminal themes diverging (e.g.
light app + dark terminal), row text and selection tint no longer
match the surface and can become low-contrast or invisible.
Derive every row color from resolvedPreviewTheme.colors via
color-mix and apply via inline style:
- selectedBg = foreground 10% over transparent
- selectedHoverBg = foreground 15%
- unselectedHoverBg = foreground 10%
- unselectedFg = foreground 75% mixed toward termBg
- mutedFg = foreground 55% mixed toward termBg (used for
"Terminals · N" counter, switch-to-split icon color, fallback Server
icon, and the username@host secondary line).
- separator = foreground 10% over termBg (right-border and
header bottom-border now use this instead of border-border/50,
which was also app-theme bound).
Hover bg swap goes through onMouseEnter/Leave rather than
hover:bg-* utilities, since Tailwind arbitrary values can't easily
inject color-mix hover variants and we want terminal-theme alpha
either way.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old compose bar had a rounded gradient card with an inset box
shadow, a bordered inner textarea, and a prominent filled Send button
— visually heavy, and sitting on top of the terminal it looked like a
separate panel instead of a prompt line.
Rework it to sit flush on the terminal-theme background, Claude Code
compose-area style:
- Outer container uses resolvedBg directly (no gradient, no rounding,
no box-shadow); separator from terminal output is a single 8%-alpha
hairline border-top.
- Textarea is fully borderless and transparent — no bg, no border, no
focus ring, no inner shadow. Text sits directly on the terminal bg.
- Send button removed entirely; Enter was already the send key, and
the filled button was just visual weight. Shift+Enter still inserts
a newline, Esc still closes.
- Close (X) button shrunk to a minimal 6x6 ghost; transparent at rest,
only gains a 10% overlay + full fg on hover.
- Placeholder bumped from opacity-40 to opacity-70 so the "press Enter
to send" hint is legible against dark and light terminal themes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The terminal-side ScriptsSidePanel was the surface the #780 reporter
was actually looking at when they asked for right-click delete/modify
on snippets. PR #783 closed the issue by adding a trash icon in the
Vault edit panel, but the sidepanel snippet rows were still plain
<button>s with no context menu — so the original complaint
("右键可以弹出一个菜单, 可以包含'删除, 修改'等操作") remained unaddressed
at the exact spot the screenshot came from.
Changes:
- ScriptsSidePanel: wrap each snippet row in a ContextMenu with Edit
and Delete items. Menu actions dispatch window events instead of
threading new callbacks — matches the existing netcatty:snippets:add
pattern the + button already uses.
- QuickAddSnippetDialog: accept an optional onUpdateSnippet prop and
listen for netcatty:snippets:edit. Prefills label/command/package
from the dispatched snippet, and on save preserves the snippet's
original tags/targets/shortkey/noAutoRun (the dialog only exposes
the three quick-edit fields). Title flips to snippets.panel.editTitle
in edit mode.
- App.tsx: pass onUpdateSnippet wired to updateSnippets(map-replace),
and register a window listener for netcatty:snippets:delete that
filters the deleted id out of snippets. Delete needs no UI so it
doesn't go through a dialog.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Replace app logo across window icon, tray, splash, and in-app brand
- public/logo.svg: new netcatty mark
- public/icon.png: regenerated 1024x1024 from new SVG (source for
electron-builder — .icns/.ico rebuilt automatically at pack time)
- public/dmg-fix-icon.png: regenerated 1024x1024
- public/tray-icon{,@2x}.png: regenerated color 16/32px for Linux/Windows
- public/tray-iconTemplate{,@2x}.png: regenerated monochrome silhouette
for macOS menu bar (background stripped, foreground flattened to
black on transparent so template-image rendering produces a clean
mask)
- components/AppLogo.tsx: render the new logo as a static <img>. The
old hand-coded inline SVG bound fills to the accent CSS variable;
the new mark has a fixed palette, so callers keep their sizing /
rounding classes via className while the asset itself is a single
file served from /public.
- index.html: splash screen now uses the same /logo.svg via <img>,
with border-radius for the rounded-square frame.
* Polish logo: theme the in-app mark, gloss the OS icon, shrink cat
- components/AppLogo.tsx: back to an inline SVG. Background rect fills
with hsl(var(--primary)) so the in-app brand follows the theme
accent (was fixed navy when imported as <img>). Cat scaled to 68%
of the frame and centred so it doesn't crowd the edges at small
sidebar sizes.
- public/logo.svg + regenerated PNGs: polished OS icon variant with a
large rounded-square clip (rx 224 on 1024), top-left spotlight
radial gradient, subtle top sheen + bottom darkening, and an inner
edge vignette for a slight chamfer. The cat is shrunk to the same
68% as the in-app logo for visual consistency.
- Monochrome tray template (macOS menu bar) is rebuilt from the
shrunk-cat path set with all fills flattened to black; keeps a
clean silhouette instead of a filled rounded square.
* Smooth paws, richer gloss on app icon
- Drop the dark toe/claw detail paths from the source illustration
(indices 22-25, 30, 35, 37, 39 — the ones tracing vertical claw
dividers inside the paws). At small sizes those read as teeth/
claws; paws now render as clean rounded blobs.
- public/logo.svg (OS icon source): richer depth pass —
* two-tone navy vertical gradient (lighter top, deeper bottom)
* brighter upper-left spotlight for glassy highlight
* top sheen + bottom darkening for sheen-across-curve effect
* soft elliptical ground shadow beneath the cat to anchor it
* 2% inner edge stroke to crisp the rounded-square chamfer
- components/AppLogo.tsx: regenerated with the same cleaned cat set,
still themed via hsl(var(--primary)). The in-app mark stays flat
(no gloss) because the effect adds nothing at 20-40px sidebar
sizes and would fight theme accents.
- All raster variants (icon.png, dmg-fix-icon.png, tray color + tray
macOS template) rebuilt from the cleaned sources.
* Respect Apple icon safe area; drop gloss, add thin border
macOS icon was rendering to the full 1024x1024 canvas, so it looked
noticeably larger than neighbour apps (VS Code, Ghostty, Zed) in the
Dock. Apple's Big Sur+ convention puts the artwork body inside an
~824x824 safe area centred in a 1024 canvas, which is how those apps
are sized.
- public/logo.svg: artwork body is now 824x824 centred with ~100px
transparent padding. Corner radius 185 (close enough to the macOS
squircle at Dock scale). Cat rescaled so it keeps the same 68%
proportion within the smaller body.
- Gloss layers (spotlight / sheen / ground shadow / vignette) removed
per request — went for a Ghostty-style clean look instead.
- Thin white inner border (stroke 3px, 22% opacity) outlines the
rounded square for definition.
- Tray PNGs for Linux/Windows keep the full-bleed variant (tray slots
expect the icon to fill the space, unlike the Dock safe area).
- components/AppLogo.tsx unchanged conceptually — it still fills its
own bounding box via hsl(var(--primary)); the Apple safe-area rule
is Dock-specific, not relevant to in-app rendering.
* AppLogo: tighten corner radius to match previous (rx 18.75%)
Previous AppLogo used rx=12 on a 64 viewBox (18.75%). The inline
replacement had rx=224 on a 1024 viewBox (21.9%), which combined
with the caller's rounded-xl class read noticeably rounder in the
sidebar. Drop to rx=192 on 1024 viewBox so the in-app mark matches
the old proportions.
* Beef up icon border so it survives Dock downscaling
3 px at 22% opacity disappeared when rasterised down to ~128 px Dock /
Launchpad size. Bumped stroke-width to 8 px and opacity to 40% so the
inner highlight reads as ~1 px at Dock scale. Stroke is inset by
stroke-width/2 so it sits fully inside the rounded-square body (no
anti-alias bleed outside the safe area). Same treatment applied to the
full-bleed tray variant.
* Enlarge cat inside icon tile (68% -> 85% of body)
Dock render had too much navy margin around the mark. Bump the cat's
scale so it fills 85% of the Apple safe-area body while keeping a
visible bezel to the rounded corners and the inner border. Tray color
variant and macOS template (scale 0.9, no border) follow the same
scale-up.
* Add ripple effect on sidebar nav and tidy logo in vault header
- Add RippleButton wrapper + ripple keyframe; use it for the six vault
sidebar nav entries (Hosts, Keychain, Port Forwarding, Snippets,
Known Hosts, Logs) so clicks get a subtle material-style ripple.
- Shrink vault sidebar AppLogo to h-8 w-8 and drop the outer rounded-xl
so the visible corner comes from the SVG's own rx instead of the
container clip.
- Relax AppLogo tile rx/ry to 144 for a more moderate corner radius.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* AppLogo: bump tile corner radius back up to rx 18.75%
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Unify manager toolbars, tighten tabs and vault sidebar title
- Manager toolbars (Keychain, KnownHosts, PortForwarding, Snippets)
normalised to h-14 / h-10 controls with bg-secondary/80 backdrop-blur
and the shared bg-foreground/5 secondary button treatment, so Hosts /
Keychain / Known Hosts / Port Forwarding / Snippets headers size and
tint identically.
- Keychain filter tabs: drop primary tint and cert-count pill; reuse
the same foreground/5 vs foreground/10 active states as other
managers. Search input grown to h-10 to match.
- Known Hosts: removed the leftover text-xs on Scan System / Import
File so they inherit Button's text-sm like every other action.
- TopTabs: drop the 2px active-accent top line and add rounded-t-md +
overflow-hidden so active tabs read as a clean soft tab shape rather
than a banner.
- VaultView sidebar: wordmark grown to text-xl font-black italic with
tightened tracking; logo gap trimmed from 3 to 2.5; outer bg dropped
from secondary/80 to flat secondary to sit flush against the
toolbars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Vault global search spans all groups/packages (#777)
Search was scoped to the current group (hosts page) or the current
package (snippets page), so a host or snippet the user wanted to find
could stay hidden unless they first navigated into the right group —
especially confusing with the "root only shows ungrouped hosts" setting
enabled.
When the search box is non-empty:
- hosts: skip the selectedGroupPath / showOnlyUngroupedHostsInRoot
filters entirely. Each matching card shows a small outline badge with
the host's group so cross-group origin is visible.
- snippets: skip the current-package filter. Hide the sub-package grid
(would be redundant alongside a flat cross-package match list). Each
snippet card shows the package path as a small badge.
Tree view already followed this "search crosses groups" shape — see
`treeViewHosts` — so this aligns the flat grid/list views with it.
* Show no-results feedback when snippet search is empty (#777)
Addresses Codex P2 review on PR #785. With the package tile grid hidden
during search and no matching snippets, the content area was blank and
the global empty state did not render (it requires snippets.length === 0).
Add a dedicated no-results panel for the "user is searching and nothing
matched but there are other snippets" case, with i18n for en and zh-CN.
* Drop group/package badges on search results (#777)
Search is itself a filter, so decorating each result card with the
group/package it came from added visual noise without adding
information. Only difference vs. pre-search rendering now is that the
result set spans all groups/packages.
* Fix snippet no-results empty state with packages present (#777)
Addresses Codex P2 on 4a778e63. The empty-state gate was
displayedPackages.length === 0, but package tiles are hidden during
search regardless of count. Any workspace that had packages was
rendering a blank content area on zero-match queries because that
guard never passed. Drop the package-count condition — the flat
snippet list is the only visible surface while searching.
* Cover package-only workspaces in snippet search no-results (#777)
Addresses Codex P2 on ccdf6afc. snippets.length > 0 also excluded
workspaces where the user has only created packages (no snippets yet).
The correct gate is the inverse of the global empty state's condition,
so we fall back whenever the workspace isn't completely empty.
* Block empty/shrunk pushes when sync base is null (#779)
The shrink guard (detectSuspiciousShrink) returned suspicious:false
whenever base was null, which is exactly the condition on a fresh
install, after unlock-key re-derivation, or when the encrypted base
blob fails to decrypt. A device in that state could push a
degraded/empty payload and overwrite populated cloud data — the
failure mode reported in #779 (Mac → OneDrive → Win11 wiping the
keychain on both ends).
Accept an optional remote-payload fallback in the guard and use it
when base is missing. Plumb the already-decrypted remote payload
from the merge branch, and decrypt checkResult.remoteFile on demand
in the direct-upload and syncAll branches when base is null.
Legitimate cases stay untouched:
- no base AND no remote → still not-suspicious (genuinely empty).
- outgoing grew past remote → lost is negative, guard skips.
- base present → behaviour unchanged, remote fallback ignored.
* Harden OneDrive 404 handling, restore barrier, multi-provider divergence (#779)
Follow-up fixes on top of the shrink-guard change for the same root
incident.
- OneDriveAdapter: findSyncFile/downloadSyncFile now retry with short
backoff when the Graph API returns "not found". A file uploaded by
another device can transiently 404 for seconds while the OneDrive
client propagates it, and treating that as "cloud is empty" was a
key step in how #779 escalated. The retry is bounded (2 extra
attempts, 1.5s/3s backoff) and only fires on null/404 results.
- useAutoSync.isRestoreInProgress: self-clear the restore-barrier
storage key when its deadline is in the past, and treat a deadline
more than 10 minutes in the future as corrupt (clock skew, pathological
holdMs, or tampered value) instead of letting it lock auto-sync.
- CloudSyncManager + SyncEvent: when the existing divergent-provider-
bases check fires, emit a PROVIDERS_DIVERGED event in addition to the
console.warn so the UI can surface the warning (was otherwise silent
and a known path for one provider's merged payload to overwrite a
differently-configured provider's data).
The keybinding recorder couldn't assign the 'Disabled' sentinel — pressing
Esc just cancels. Add a Ban-icon button next to 'Reset to default' that
writes 'Disabled' for the active scheme, and render the button label using
the localized 'Disabled' string instead of the raw sentinel.
A right-click Delete already exists in the snippet grid's context menu,
but users overwhelmingly open snippets by clicking — and the edit panel
had no delete affordance, so many concluded the feature was missing.
Surface a Trash2 icon next to Save when editing an existing snippet;
it calls the existing onDelete and closes the panel.
* Preload compact history on first turn after app restart (#753 hedge)
Symptom (confirmed on Copilot CLI, originally reported on Codex in
#753): after closing and reopening Netcatty, the AI chat UI still
shows the prior conversation but the agent responds "this is the
beginning of our conversation, no previous records". Earlier context
is lost entirely.
Root cause: the bridge relied on session/load throwing "not found" to
trigger the catch-block fallback that replays compact history. Some
ACP agents (Copilot CLI, some Codex builds) silently spawn a new
session when handed a stale id instead of erroring. The catch-block
never fires → historyReplayFallback stays false → the first turn
sends only the latest prompt → agent sees zero context.
Fix: when we're creating a new provider process AND telling it to
resume an existing session id AND the renderer gave us compact
history, preload historyReplayFallback=true as a hedge. If the agent
really did reload the session, the replay is ~3KB of redundant
context (small waste). If the agent silently started fresh, the
replay restores durable constraints + last few raw turns so the
first response is coherent.
After the first successful streamed turn clears the flag (the round-2
post-stream hook), steady state is back to sending only the latest
prompt. Cost is bounded to one replay per app-restart-and-prompt.
Test: "replays compact history on the first turn after app restart
even when session/load 'succeeds'" — mocks createACPProvider to
behave like Copilot CLI (no error thrown, no real resume), asserts
the first streamText call carries history+latest (length 2) and the
second only latest (length 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix AI session resume and agent switching
* Preserve hidden draft when switching agents
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: when an AI request is proxied through nginx (or any gateway)
and the request body exceeds client_max_body_size, the proxy returns a
413 HTML error page. The Vercel AI SDK then fails to parse the HTML
as a chat completion and surfaces a cryptic Zod validation error like
"Expected 'id' to be a string." through the UI — users have no idea
what's wrong.
Root cause: classifyError only did light sanitization and returned the
raw SDK message. It also string-coerced the error before inspection, so
the structured statusCode / responseBody fields that APICallError
attaches were thrown away.
Fix: classifyError now accepts `unknown` and inspects the full error
shape. Adds explicit branches for:
- HTTP 413 (from statusCode, cause.statusCode, or message text) →
"Request too large — exceeded proxy size limit. Try shorter
message, fewer attachments, or raise client_max_body_size."
- HTTP 502/503/504 → retryable upstream-gateway message
- HTML response body (starts with <!DOCTYPE/<html> or contains such
tags anywhere) → "Server returned HTML error page, likely a proxy
intercept."
- Zod/schema parse shapes ("Expected 'X' to be …", "Invalid JSON
response", "Type validation failed") → "Response could not be
parsed; proxy may have replaced/truncated the body."
In every classified case the raw SDK text is still appended ("Raw: …")
so users can report the underlying error verbatim.
useAIChatStreaming.ts callers now pass the raw error to classifyError
instead of `.message`, so the new structured branches actually fire.
Also wired infrastructure/ai/*.test.ts into the npm test glob.
Closes#765
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Batch Windows hidden-attribute detection in local FS listing (#766)
Symptom: opening a local directory with ~800 files in the SFTP panel
hangs for ~30 s on Windows. Reported on netcatty 1.0.93.
Root cause: listLocalDir spawns attrib.exe once per entry inside the
worker pool to detect the Windows hidden flag. 800 subprocess spawns
× ~40 ms each is precisely the reported 30 s. fs.promises.stat and
readdir on their own are nearly free; the subprocess flood dominates.
Fix: replace the per-entry attrib call with a single
`attrib.exe "<dir>\*"` invocation up front, parse its output into a
Set<basename>, and have the workers do an O(1) set lookup. One
subprocess per directory listing instead of one per entry.
Expected speedup for the #766 case: ~30 s → <1 s. Behavior is
unchanged — hidden files keep their hidden flag, non-hidden files
stay not-hidden; only the mechanism is different. Broken-symlink
handling (lstat fallback) also uses the same set.
Tests:
- parseAttribOutput is extracted as a pure function and unit-tested
against real attrib output shapes: drive-letter paths, UNC paths,
the trailing [DIR] marker that some Windows versions emit, mixed
flag columns (A/H/R), malformed "Parameter format not correct"
lines, empty input.
- listWindowsHiddenBasenames short-circuits on non-Windows without
spawning anything.
- Parser uses path.win32.basename explicitly so the tests pass under
non-Windows CI.
I cannot reproduce or test on Windows directly. The diagnosis is
mechanical (we can count subprocess calls) and the fix is a local
rewrite that preserves behavior, but Windows verification is still
desirable before release.
Closes#766
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex review on #767: pass /d so batched attrib includes hidden directories
Codex flagged that attrib.exe treats `<dir>\*` as file-centric by
default — without `/d`, hidden directories (node_modules, .git, etc.)
never appear in the output, so listWindowsHiddenBasenames misses them
and the SFTP browser shows those folders as not-hidden. This is a
behavior regression from the per-file path, which passed each entry's
full path directly and therefore covered both files and directories.
Added `/d` to the execFileAsync argv and a regression test that
module-mocks child_process.execFile to capture the argv and assert
`/d` is present. The parser-level [DIR] marker test is also still
there, so both the attrib call shape and the parser behavior are
locked down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 2 on #767: tighten [DIR] strip to the literal marker
Codex flagged that /\s+\[[^\]]+\]\s*$/ also swallows legitimate trailing
bracketed text, so a hidden file named "Notes [old]" gets stored as
"Notes" in hiddenSet and hiddenSet.has("Notes [old]") returns false —
the entry is misclassified as not-hidden, a regression from the old
per-entry attrib path which never saw a "[DIR]" marker to strip.
Narrowed the regex to /\s+\[DIR\]\s*$/ — only the literal attrib/d
marker. Added a regression test covering "Notes [old]", "Draft [v2].md",
"archived [2024]" alongside the existing [DIR] case to lock down both
behaviors together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix ACP history replay and compaction
* Fix PR keyword importance matching
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address codex review on #754: preserve short constraints + cancel-clear
Two recovery-path regressions flagged by codex review:
1. Compact ACP history dropped short load-bearing user constraints
(acpHistory.ts:55). The blanket length<10 rule treated short
non-trivial messages like "Use ssh2" or "中文输出" as filler,
while longer generic follow-ups still ate the budget. After
stale-session recovery the fresh ACP session would resume without
constraints that were present in the original chat. Removed the
length heuristic; the TRIVIAL_USER_MESSAGE_PATTERNS regex already
filters actual filler ("ok", "yes", "继续", "thanks").
2. historyReplayFallback was only cleared on non-aborted streams
(aiBridge.cjs:2837). If the user stopped the first turn after
stale-session recovery, the flag stayed set. The next turn would
then trigger shouldResetProviderForHistoryReplay, discard the
freshly recovered ACP session (resumeSessionId is forced to
undefined in that path), and re-spend tokens on another compact
replay — breaking the cancel-preserves-session contract. Now we
also clear on abort; the empty-but-not-aborted retry path in the
if-branch above is unchanged.
Tests:
- New test in acpHistory.test.ts asserts "Use ssh2" / "中文输出"
survive when pushed outside the recent raw window
- New test asserts "ok" / "继续" still drop (sanity check that the
trivial regex still does its job without the length backstop)
- Updated "does not treat pr inside ordinary words as important" to
no longer assert that approach/improve/prepare are absent — the
test's real intent (priority-2 line still wins) is preserved by
the 不要提交 assertion
- New test in aiBridge.test.cjs simulates a user cancelling the first
turn after recovery and verifies the next turn reuses the
recovered session (no extra provider creation, no re-replay)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex re-review: preserve replay flag across orthogonal recreation + keep tool output in raw window
Two more P2 regressions flagged on the second review pass:
1. historyReplayFallback was only carried over in the reset-for-replay
branch of the provider recreation path. An orthogonal change between
an empty recovered turn and its retry — a permission-mode toggle,
MCP scope/fingerprint flip, or auth rotation — would flip
shouldReuseProvider to false, enter the !shouldReuseProvider branch,
and drop the flag because preserveHistoryReplayFallback only covered
the shouldResetProviderForHistoryReplay case. The next turn then
sent only the latest prompt and lost the recovered conversation.
Now the flag is preserved on any recreation where a replay is still
pending.
2. Tool messages didn't flow through toRawHistoryMessage at all, so on
stale-session recovery they only survived as the 500-char compact
summary in summarizeToolMessage. Any follow-up referencing the last
tool output ("use that output", "what did cat show?") lost the
actual bytes when they exceeded the compact cap. Now tool results
travel through the recent raw window up to MAX_RAW_MESSAGE_CHARS
(2000), flattened to the "assistant" role since ACP only accepts
user/assistant.
Tests:
- aiBridge.test.cjs: new "preserves history-replay across provider
recreation caused by permission-mode / MCP / auth change" —
exercises the gap via a permission-mode toggle between an empty
recovered turn and its retry. Extends mock to support a dynamic
getPermissionMode.
- acpHistory.test.ts: new "preserves recent tool results verbatim" —
pushes a ~1500-char tool output through the pipeline and asserts the
replay still contains enough bytes to exceed the 500-char compact
cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 3: inline tool_call context + bound durable scan
Two findings from the third codex review pass, both legitimate:
1. [P2] When the raw window starts mid-tool-interaction, the preceding
assistant tool_call message can fall outside the 6-item slice while
the tool_result stays in. Without the call's name+arguments, the
result was opaque bytes and follow-ups like "use that output" had
no provenance. The compact pass only preserved calls that matched
IMPORTANT_PATTERNS, so read_file / grep / terminal_exec were
silently dropped.
Fix: build a toolCallId → { name, arguments } index from every
assistant message and inline a `[from <name>(<args>)]` label next
to each Tool result line in the raw window. Args are truncated to
MAX_TOOL_CALL_LABEL_CHARS (200) so a verbose JSON payload can't eat
the entire raw budget.
2. [P3] buildCompactContext scanned messages.entries() over the full
transcript for durable-user/assistant candidates, even though
MAX_MESSAGES_TO_SCAN (20) suggested the path was meant to be
bounded. On a long ACP chat, every send did O(N) regex work plus
an O(N log N) sort — the very chat-length-dependent latency the
token-compaction PR was meant to address.
Fix: introduce MAX_DURABLE_SCAN_MESSAGES (200) and restrict the
durable scan to that tail. 200 is large enough to cover realistic
sessions (99th-percentile chats are << 200 turns) while giving a
constant-time worst case. Constraints older than the window age
out of the compact replay; the live ACP provider's own persisted
session still carries them when it can resume, which is the
common path.
Tests:
- "inlines tool_call name+args so tool_result is interpretable without
the preceding assistant turn" — pushes the tool_call out of the raw
window and asserts the result line carries [from <tool>(<args>)].
- "bounds the durable-candidate scan to avoid O(N) work per send on
long chats" — builds a 600+ message chat with an ancient priority-2
constraint outside the scan window and a recent one inside; asserts
only the recent one survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 4: preserve short assistant decisions + provenance on older tool results
Two P2 findings from the fourth codex pass, both mirror-images of earlier
fixes on a different code path:
1. Short assistant decisions dropped from compact replay
(acpHistory.ts:75-83). isSubstantiveAssistantMessage required length
>= 40 OR a small English keyword match OR a numbered list. Short but
load-bearing replies like "Use ssh2", "rebase instead", "中文输出"
satisfied none of those and were silently dropped from the durable-
assistant compact section. Once they fell outside the 6-item raw
window, "do what you suggested earlier" would replay only the user
question without the assistant's actual decision.
Fix: mirror the user-side loosening — drop the length/keyword gate,
rely on TRIVIAL_ASSISTANT_MESSAGE_PATTERNS to filter actual filler
("ok", "ack", "got it", "明白").
2. Older tool results lost provenance (acpHistory.ts:108-114). The
raw-window fix (round 3) only covered the last 6 items. Once a tool
result fell into the compact section via summarizeToolMessage, the
paired assistant tool_call was usually gone too, so multiple older
outputs surfaced as indistinguishable "Tool result (callN): ...".
Follow-ups like "use the resolv.conf output" had no way to map to
the right call.
Fix: plumb the toolCallIndex through summarizeMessage →
summarizeToolMessage and inline `[from <name>(<args>)]` labels in
the compact section too, the same shape the raw window uses.
Tests:
- New: preserves short non-trivial assistant decisions that miss the
keyword heuristic (Use ssh2 / 中文输出 / rebase instead)
- New: still drops trivial assistant filler like 'ack' / 'ok' / '明白'
- New: inlines tool_call context on OLDER summarized tool results
- Updated earlier raw-window tool regex tests to match the [from X(Y)]
shape ([^)] was failing to cross the args JSON's closing paren)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 5: de-dup raw ∩ compact + wire userSkills test into npm test
[P2] The scanned loop (last 20) overlaps with recentRaw (last 6), so
without a raw-window skip in the summarizeMessage path the same last-6
turns were summarized into the compact section AND appended verbatim
in the raw section. Important user turns and large tool output paid
the budget twice — eating into the 3k compact cap and crowding out
older durable context the replay is meant to preserve. Added the
same recentRawSourceIds skip the durable-user / durable-assistant
passes already use, and a regression test that asserts markers inside
the raw window don't surface in compact while still appearing in raw.
[P3] electron/bridges/ai/userSkills.test.cjs (added by this PR) sat
in a subdirectory that the default "npm test" glob
(electron/bridges/*.test.cjs) didn't pick up. The new routing /
index-budget regressions would never run locally or in CI until
someone noticed. Extended the glob to also match
electron/bridges/*/*.test.cjs; the userSkills tests are now included
in the 148-test run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 6: cancel+immediate-send race + tool-call id collision
Two P2 regressions in the recovery path:
1. If the user clicks Stop and immediately sends the next prompt, the
new stream handler's existingRun path unconditionally called
cleanupAcpProvider — destroying the fresh ACP session the cancel
IPC had just promised to preserve. The round-2 clear-on-abort
fix ran too late (in post-stream code) to help, because the new
stream can arrive before the aborted stream fully unwinds. In
that common timing window the follow-up still started from a
bare provider and lost all recovered conversation state.
Fix: (a) cancel IPC now synchronously clears
historyReplayFallback on the preserved provider entry, so the
next stream can't trigger shouldResetProviderForHistoryReplay
and tear the session down via that path; (b) the existingRun
path skips cleanupAcpProvider when the prior run was already
cancelled via the cancel IPC (captured via existingRun.cancelRequested
before we overwrite it). True interrupt-and-restart without an
explicit cancel still falls back to the old clean-slate behavior.
2. The tool-call provenance index used raw toolCall.id as the key.
Nothing in ChatMessage or the ACP event path enforces per-chat
unique ids, so a provider reusing "call1" across turns would
overwrite the older entry and mis-label older tool results
(e.g., an /etc/hosts result annotated as /etc/resolv.conf in
the compact summary). That makes stale-session recovery
misleading whenever a follow-up refers back to an earlier tool
output.
Fix: key the index by `${toolResultMessageId}:${toolCallId}` and
walk the message stream in order, resolving each tool_result to
the most recent preceding assistant tool_call with matching id.
Each result keeps its own historically-correct label regardless
of later id reuse.
Tests:
- aiBridge: "preserves recovered ACP session when user cancels then
immediately sends the next prompt" — fires the next stream request
after cancel but BEFORE releasing the first stream's blocked read,
asserts providerCreationArgs.length stays at 2 (no third creation)
and the second turn sends only the latest prompt.
- acpHistory: "resolves tool_call provenance correctly when tool ids
are reused across turns" — two interactions sharing id "call1",
asserts each tool_result carries its own call's args label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex round 7: turn-based scan bound + single-pass history build
Two P2 regressions in long-chat / tool-heavy recovery paths:
1. MAX_DURABLE_SCAN_MESSAGES (200) bounded the scan by raw message
count. ACP tool interactions store the user turn, assistant
tool_call turn, and each tool_result as separate messages, so a
tool-heavy chat can produce 5+ messages per logical turn. 200
messages could be only 30-40 user turns — early constraints
like "不要提交" from turn 5 fell out of the compact replay long
before the turn count justified aging them out.
Fix: bound by MAX_DURABLE_SCAN_TURNS (100 user turns) instead.
Walk backwards from the end and stop after seeing 100 user
messages. Realistic tool-heavy 30-turn chats now keep their
early constraints alive, while true 100+ turn chats still
benefit from the bound.
2. buildToolCallIndex(messages) and messages.flatMap(...).slice(-6)
both walked the entire transcript on every send, even after the
bounded compaction window landed. Compaction's stated purpose
was to remove chat-length-dependent latency, but these per-send
linear passes kept it.
Fix: compute the scan start once via computeDurableScanStart,
then do all subsequent work over messages.slice(durableScanStart).
buildToolCallIndex walks only the window; the raw-6 flatMap also
runs over the window. On a 1000-message chat with 100-turn
window, send-time cost drops from O(1000) to O(~window_size).
Acceptable trade: if a tool_call's matching tool_result straddles
the window boundary (result inside, call outside), the single
surviving result loses its [from X(Y)] label. Tool_calls and their
results are almost always adjacent, so this affects at most the
first 1-2 messages of the window.
Tests:
- "preserves an early constraint in a tool-heavy chat where message
count balloons past the raw-count limit" — 35 turns × 6 msgs/turn =
212 messages. The old bound would have dropped the early
EARLY_CONSTRAINT_MARKER; with turn-based bound it survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three bulk-close items to the right-click context menu on tabs:
- Close Others
- Close Tabs to the Right
- Close All
Anchor is the right-clicked tab (matches VSCode/JetBrains/FinalShell
UX), not the active tab. The "to the right" item is disabled when the
anchor is already the rightmost tab; "Close Others" is disabled when
it's the only tab.
To avoid spamming a busy-shell modal per tab, the new closeTabsBatch
helper in App.tsx expands workspace ids into their session ids, runs
ONE confirmIfBusyLocalTerminal probe across the whole batch, and only
proceeds when the user confirms. The probe + close path itself reuses
the existing PR #739 plumbing (ptyProcessTree + confirmCloseBusy).
Closes#748
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add opt-in setting to preserve mouse selection across keystrokes
Closes#755.
xterm.js hardcodes a "clear selection on user input" listener
(SelectionService.ts: coreService.onUserInput → clearSelection) with
no public option to disable. The user-reported workflow this breaks:
select a path with the mouse, type a command prefix like `sz `, then
middle-click-paste the still-live selection — but the very first
keystroke wipes the selection, so there's nothing left to paste.
Modern terminals (iTerm2, GNOME Terminal, Windows Terminal) preserve
the selection across input by default. We expose this as an opt-in
toggle for now since the visual semantics are a behavior change.
Implementation is capture-and-restore via xterm.js public APIs
(getSelectionPosition / select); xterm clears the selection
synchronously, then a queueMicrotask reapplies it on the next tick.
A ref (isRestoringSelectionRef) gates copy-on-select so the restore
doesn't redundantly rewrite the clipboard and clobber whatever the
user copied elsewhere in between.
Defaults to false (opt-in); can flip to default-on later if reception
is positive. Selection still clears on:
- Mouse click in empty space (xterm's mouse-driven path is untouched)
- Terminal scroll past the selected rows (existing buffer-trim logic)
- Programmatic clearSelection() callers
Files:
- domain/models.ts — new field, default false
- application/syncPayload.ts — added to SYNCABLE_TERMINAL_KEYS
- components/terminal/runtime/createXTermRuntime.ts — capture in
attachCustomKeyEventHandler, restore via queueMicrotask
- components/Terminal.tsx — owns isRestoringSelectionRef, passes it
through context, checks in copy-on-select listener
- components/settings/tabs/SettingsTerminalTab.tsx — UI toggle
- application/i18n/locales/{en,zh-CN}.ts — labels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Trim verbose i18n descriptions to match neighboring rows
Both clearWipesScrollback and preserveSelectionOnInput descriptions
were too long. Cut to one sentence each, matching the brevity of
adjacent rows like Bracketed paste and OSC-52. Historical context and
edge-case caveats belong in the changelog/PR, not the settings UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: in the Settings window (especially AI > Add Provider, but also
seen in Add Host), clicking an input occasionally shows no caret and
typed characters don't appear, yet select-all + delete still works on
the input's content.
Root cause: PR #502 introduced settings-window prewarming and
hide-on-close reuse. On Windows, calling `BrowserWindow.focus()` from
a non-foreground process is restricted by SetForegroundWindow rules —
the window is shown on top but never actually receives OS foreground
focus. With `document.hasFocus() === false`, Chromium deliberately
suppresses caret blink and keyboard routing, even though clicking an
input still moves activeElement to it (so non-keyboard interactions
like select-all-then-delete keep working — exactly the reported
symptom).
Fix: introduce `showAndFocusWindow(win)` and call it everywhere the
settings window is shown:
- Apply the alwaysOnTop toggle on win32 to bypass the
SetForegroundWindow restriction (established Electron workaround)
- Always call `webContents.focus()` after `win.focus()` so the renderer
marks the document as focused regardless of what the OS decided —
this is what restores the caret + keyboard routing
Scope intentionally limited to the settings window (the path PR #502
introduced). Other windows use a different show path (ready-to-show
event) and were not reported to have the issue.
I cannot test this on Windows directly. The fix follows a
well-documented Electron pattern and the diagnosis matches the
reported symptoms (Windows-only, intermittent, post-1.0.81 only).
Closes#760
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Honor CSI 3 J by default; add toggle to preserve scrollback on `clear`
Default `clear` (ncurses ≥ 2013) emits CSI 2 J + CSI 3 J to wipe both
visible screen and scrollback. PR #633 unconditionally intercepted CSI
3 J to keep history across `clear`, which broke POSIX semantics — users
running standard `clear` could not wipe scrollback at all (#757).
Restore the standard behavior as the default and expose a toggle for
the iTerm2-style "preserve history" preference (matches what #622
asked for):
- domain/models.ts: add `clearWipesScrollback: boolean` (default true)
- createXTermRuntime.ts: CSI 3 J handler now reads the setting and
only intercepts when the user opts out
- SettingsTerminalTab.tsx + i18n: expose the toggle with a description
explaining the tradeoff
- The right-click "Clear Buffer" menu action keeps its independent
semantics (always preserves scrollback) regardless of this setting,
since it goes through `clearTerminalViewport`, not the CSI path
Closes#757
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: include clearWipesScrollback in cloud-sync terminal keys
Codex review on PR #761 caught that the new toggle was added to
TerminalSettings but not to SYNCABLE_TERMINAL_KEYS, so it would never
travel across devices via cloud sync — users disabling it on one
device would silently get the default back on another after sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#741. Bash/zsh use Tab for native completion, but our ghost-text
accept on single Tab was swallowing the keystroke before it reached the
PTY. Ghost text is still accepted with →; Tab in popup-menu mode is
unchanged (popup is an explicit UI so intent is clear).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Improve tab UX: insert duplicated tabs adjacent to source, enable wheel scroll on tab bar
Addresses #737.
- Duplicating a tab now inserts the new tab immediately after the source
in the tab order, instead of appending it to the far right where it
was hard to find with many tabs open.
- The top tab strip now translates vertical mouse-wheel deltas into
horizontal scrolling, so users with many tabs can reach the ends of
the strip without dragging. Trackpad gestures that already carry
horizontal delta are left alone to preserve native two-finger swiping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Codex review: read source session inside functional updater
Codex flagged that reading `session` from the closure broke the atomicity
guarantee of the previous implementation — rapid repeated duplicates could
miss freshly queued state.
- Pre-allocate the new session id outside both setters so it stays stable
across StrictMode double-invocations.
- Move the source lookup back into `setSessions`' functional updater so it
always reads the freshest committed/queued state.
- Drop `sessions` from the useCallback dependency list now that we no
longer read it.
- Fast-path tabOrder insertion when the source is already in tabOrder to
avoid re-deriving the full effective order in the common case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Codex review: gate active-tab and tab-order updates on successful create
Codex flagged that `setActiveTabId(newSessionId)` and `setTabOrder(...)` ran
unconditionally even when `setSessions` bailed out (source tab was closed
before the duplicate handler ran). That left activeTabId pointing at an id
that was never appended to sessions, putting the terminal layer into an
invalid "no matching tab" state.
Move both nested setState calls inside the `setSessions` functional updater
so they only fire when the source is actually present. Mirrors the original
pre-PR pattern; nested updates are idempotent so StrictMode's
double-invocation is harmless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync-guard): extend SyncState with BLOCKED + add shrink event variants
* feat(sync-guard): add detectSuspiciousShrink pure function with 12 unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* polish(sync-guard): drop unnecessary cast, sharpen test naming, pin priority invariant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(test): include domain/*.test.ts in npm test glob
* feat(sync-guard): gate syncToProvider with shrink detection + force-push override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): reset overrideShrinkOnce before early return for invariant strictness
* fix(sync-guard): extend shrink guard to syncAllProviders (the actual sync entry point)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync-guard): apply empty-vault guard uniformly to auto and manual sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync-guard): preserve merge base on same-account re-auth
Adds providerAccountId persistence; completePKCEAuth and completeGitHubAuth
now only clear syncBase/anchor when the authenticated account id differs from
the previously stored one, preventing zombie-entry resurrection on token
refresh. disconnectProvider clears the stored id so a reconnect starts fresh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync-guard): add i18n strings for sync-blocked banner + force-push modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync-guard): add SyncBlockedBanner showing shrink findings with restore/force-push actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): stable subscribeToEvents reference + type-safe finding narrowing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync-guard): force-push confirmation modal + scroll restore button into view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ux(local-backups): show version as title, demote reason+timestamp to meta line
* feat(local-backups): record + display sync data version (v5/v6...) on each backup
Each backup now captures the live CloudSyncManager.localVersion at creation
time. UI shows it as title (v5, v6, ...) with timestamp + reason demoted to
the meta line. Backups created before this field existed (or before any
successful cloud sync) fall back to timestamp as title.
Replaces the earlier app-version-transition title which conflated app
version with sync data version.
* fix(sync-guard): consume override flag at sync entry + restore provider status on block
- Snapshot+clear overrideShrinkOnce at top of syncToProvider and
syncAllProviders so an early-return cannot leak the flag to a later
unrelated sync (Codex P1).
- Restore provider status to 'connected' when shrink-block returns from
syncToProvider; previously left provider stuck on 'syncing' in the
UI (Codex P2).
- Process pre-existing check errors before returning from the
shouldBlockAll branch in syncAllProviders so a check-failed provider
isn't dropped from results (Codex P2).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): refactor force-push to parameter passing + add credential-availability guard
The previous design used a one-shot boolean flag on CloudSyncManager set
by forcePushOverrideShrink(). Even with snapshot+clear at sync entry
points, the renderer wrapper's await ensureUnlocked() could throw before
the flag was consumed, leaving it armed for the next unrelated sync.
Fix: pass overrideShrink as a call-time parameter through the chain.
Eliminates the persistent flag and its leak surface.
Also: force-push now runs the same ensureSyncablePayload(...) guard the
other manual sync entry points use, so a vault with encrypted-credential
placeholders won't be uploaded via the force path either.
Addresses the latest two Codex P1/P2 findings on #742.
* fix(sync-guard): backfill account id from in-memory state for upgrade-path re-auth
Users upgrading to this PR have no netcatty.sync.accountId.* persisted yet.
On their first re-auth the guard saw previousId=null and cleared the
merge base anyway, defeating the point of the same-account preservation.
Snapshot the in-memory account id BEFORE overwriting providers[provider]
and use it as a fallback when the persisted id is missing. New users
(no prior connection at all) still get the clear-on-first-auth path.
Addresses Codex P1 on #742.
* fix(sync-guard): inspect force-push results + mark blocked single-provider as error
- Force-push handler now inspects syncNow result entries: applies any
mergedPayload to local state, only clears the banner when all providers
report success, surfaces a toast error otherwise. Previously the banner
cleared unconditionally regardless of network/auth failures (Codex P1).
- syncToProvider shrink-block branches now mark provider status as
'error' with a 'Sync blocked: would delete too much' message instead
of 'connected'. Status aggregators treat 'connected' as healthy, so
the blocked upload was surfacing as 'synced' in the UI (Codex P2).
syncAllProviders already used this pattern; this brings the
single-provider path in line.
* fix(sync-guard): exempt USE_LOCAL conflict + clear post-merge BLOCKED + expose 'blocked' status
- USE_LOCAL conflict resolution now passes { overrideShrink: true }: the
conflict modal already served as user confirmation, and shrink-blocking
it left users with a closed modal and an opaque banner (Review C-1).
- Post-merge round-trip in useAutoSync now detects shrink-blocked results
and resets syncState to IDLE via new manager.clearShrinkBlockedState().
The merged data is already applied locally; the next user-triggered
sync will re-check, and we don't wedge the manager in BLOCKED with no
visible banner outside the Settings tab (Review I-1).
- overallSyncStatus now reports 'blocked' as a distinct value from
'error', so downstream UI (status icon, future badges) can offer
shrink-block-specific affordances (Review I-2).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): stabilize banner subscription dep + map 'blocked' status to error indicator
- The SyncBlockedBanner subscription useEffect depended on [sync] (the
whole hook return object), which gets a new reference every render.
This caused the listener to be unsubscribed+resubscribed on every
render, opening a tiny race window where a SYNC_BLOCKED_SHRINK event
could be missed and the banner would never appear. Destructure
subscribeToEvents (already useCallback-stable) and depend on it
directly, so the effect runs exactly once on mount.
- SyncStatusButton's status mapping had no arm for the new 'blocked'
value, falling through to 'none' (idle). The global status indicator
said healthy while the in-page banner said paused. Map 'blocked' to
the same error indicator used for 'conflict' so the UI is consistent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): only clear banner on actual success + hydrate from manager state
- Banner subscription now clears only on SYNC_COMPLETED with result.success.
SYNC_STARTED (auto-sync timer ticks) and SYNC_FORCED (fires BEFORE upload)
could clear the banner prematurely, removing the user's recovery affordance
while the underlying issue was unresolved (Codex P2).
- Manager now persists the last shrink finding in state.lastShrinkFinding
alongside the SYNC_BLOCKED_SHRINK emission. New public getter
getShrinkBlockedFinding() returns it when syncState is BLOCKED. Renderer
hydrates the banner on mount so a block that happened off-screen
(auto-sync while user was on another tab) is still visible when they
open Sync Settings (Codex P2).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): unified BLOCKED-cleared event + USE_LOCAL inspects results
- USE_LOCAL conflict resolution now inspects syncNow() results, applies
any mergedPayload to local state, surfaces a toast error and KEEPS the
modal open on failure (so user can switch to USE_REMOTE). Mirrors the
force-push handler pattern. Without this, USE_LOCAL silently 'succeeded'
even when providers failed (Codex CLI P1).
- New SYNC_BLOCKED_CLEARED event emitted on every BLOCKED -> non-BLOCKED
transition via a private exitBlockedState() helper. Banner subscribes to
this single signal instead of guessing from per-provider SYNC_COMPLETED
events. Fixes:
- Multi-provider scenarios where first SYNC_COMPLETED clears the banner
while a later provider was still going to fail (Codex CLI P1).
- clearShrinkBlockedState() (post-merge self-heal) silently leaving
the banner stuck because no event was emitted (Codex CLI P2).
- disconnectProvider() now also exits BLOCKED state. Disconnecting
implicitly resolves any pending shrink-block warning, otherwise the
stale alert carried over to the next-account reconnect (Codex CLI P2).
- All BLOCKED -> non-BLOCKED transitions consolidated through
exitBlockedState() so lastShrinkFinding cleanup + event emission are
always paired (Codex CLI P3 #6 covered).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sync-guard): only clear BLOCKED on actual success, not on transient ERROR/SYNCING/CONFLICT
Previous patch called exitBlockedState() at every BLOCKED -> non-BLOCKED
transition, but this clears the banner on transitions that don't actually
resolve the shrink concern:
- SYNCING (sync just started — about to try, may fail)
- ERROR (transient transport failure, shrink concern still real)
- CONFLICT (separate concern; doesn't resolve the shrink)
If a user was in BLOCKED then triggered a sync that failed for an unrelated
reason (network, auth), the banner cleared and they lost the warning.
Restrict exitBlockedState() to terminal-success transitions:
- IDLE on successful upload (data made it to cloud — concern resolved)
- explicit clears (disconnectProvider, clearShrinkBlockedState)
- conflict resolution (USE_REMOTE/USE_LOCAL also end in IDLE)
Found by Codex CLI review of commit 12d7fa7b.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature
* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite
- Replace `comm=` with `args=` in defaultListPosix so the full command
line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
same-pid re-registration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): add i18n strings for close-busy-terminal dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ctrl-w): auto-close workspace when its last session is closed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): sidebar close wins over focused terminal in priority chain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater
Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: correct terminal AI history resume behavior
The previous implementation plan mistakenly treated reopening an old terminal AI session in a fresh or reconnected SSH tab as a scope-retargeting feature.
The intended rule is draft-first:
- a fresh or reconnected terminal opens on a blank draft
- older chats remain available in history for manual access
- selecting history does not imply automatic scope transfer into the new tab
This change is a rule correction, not a conflict between product rules.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: harden ai draft transitions
* fix ai session continuation from history
* fix: clear stale activeSessionIdMap entry when view resolves to draft
Addresses the Codex P2 review on aiPanelViewState.ts:38. When a terminal
scope mounts with a persisted activeSessionIdMap entry but no explicit
panelView and no draft, resolveDisplayedPanelView now returns the
default draft view (terminal fresh-start behavior). The sync effect
that writes into activeSessionIdMap is guarded by `if (!activeSession)
return`, so the old entry stays put. That stale entry then leaks into
activeTerminalTargetIds in every other scope, and
getSessionScopeMatchRank uses it to suppress host-matched history that
is actually resumable — so valid sessions vanish from the history
drawer until another action rewrites the map.
Add a dedicated effect that clears the scope's activeSessionIdMap
entry whenever the resolved panel view is draft but a persisted
session id is still present. This keeps the map an accurate record of
"which session each scope is currently showing" instead of a lagging
snapshot.
Also extend sessionScopeMatch.test.ts to cover the rank=2 exact-match
branch and the scope-type mismatch short-circuit, which were missing
from the original suite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: track cross-terminal session ownership by session id, not targetId
Addresses the Codex follow-up review on commit 345244b2. When a user
resumes a session from history into a different terminal, the session's
`scope.targetId` still points at the original terminal. The previous
ownership tracking — which checked whether `session.scope.targetId`
appeared in `activeTerminalTargetIds` (derived from the keys of
`activeSessionIdMap`) — therefore:
- could not prevent the same session from being resumed in multiple
terminals simultaneously, because the resumed session's targetId
never matches the current scope's targetId; and
- let `pruneInactiveScopedSessions` treat a session as orphaned and
clear its `externalSessionId` the moment the original terminal
closed, even though another terminal was actively using it.
Switch ownership to be keyed on session id:
- `getSessionScopeMatchRank` now takes `activeTerminalSessionIds`
(a Set of session ids currently displayed by other terminal scopes)
and returns rank 0 when `session.id` is in that set.
- `AIChatSidePanel` derives `activeTerminalSessionIds` from the
*values* of `activeSessionIdMap`, excluding the current scope's key.
- `pruneInactiveScopedSessions` gains an `activeSessionIds` parameter;
sessions whose id is in this set are never reported as orphaned and
never have their `externalSessionId` cleared, regardless of their
stored `scope.targetId`.
- `cleanupOrphanedAISessions` computes the in-use set from the
pre-cleanup `activeSessionIdMap`, filtered to live scopes, and
passes it through. The map is read once and reused.
Tests cover the new id-based ownership, the rank-2 exact-match path,
the scope-type-mismatch short-circuit, and the
"resumed-elsewhere session must not be cleaned" invariant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(ai-chat): fit-to-content popovers and keyboard nav for @/slash menus
- Shrink the @ host and /skill popovers to their content width
(auto width with min 220px, capped at the input width) instead of
always filling the full input width, which left large empty gutters
when the list was short.
- Add keyboard navigation: ArrowUp/ArrowDown cycle through items,
Enter commits the highlighted item, Escape closes the menu. Mouse
hover stays in sync with the active index so keyboard and pointer
agree on which row is current. Enter does not fall through to
submit while a menu is open.
- Expose aria-selected / aria-activedescendant for screen readers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(ai-chat): tone down popover radius to match other menus
The @ and /skill popovers used rounded-[20px]/rounded-[16px] which
stood out against every other popover in this file (rounded-lg with
rounded-md items). Switch to the shared radii and drop shadow-2xl for
the standard shadow-lg so the surface feels consistent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style(ai-chat): tighten mention popover spacing
- Drop the redundant "Hosts" / "User Skills" header row — the @ or /
trigger already makes the popover's purpose obvious, and the header
added ~30px of vertical whitespace above a single-line list.
- Shrink wrapper and item padding (p-2.5/px-3 py-1.5 -> p-1/px-2 py-1)
and remove the mt-0.5 gap between title and subtitle.
- Hide the hostname subline when the label already contains the
hostname (common case: "Rainyun-114.66.26.174" as label and
"114.66.26.174" as hostname — no need to repeat).
- Lower minWidth 220 -> 200 so short lists can shrink further.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ai-chat): address Codex review on PR #726
- Reset active menu index on any change to the *set* of visible items,
not just its length. Watching only `.length` let Enter commit a
different item when the slash query changed to a same-sized match
set. Derive a stable identity key (sessionIds / skill ids) and use
that as the effect dep instead.
- Clamp the popover's minWidth to the measured panel width so narrow
layouts don't end up with minWidth > maxWidth, which CSS resolves
by honoring min and clips the menu off-screen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fish's `eval` builtin does not recognize `--` as an end-of-options
marker, so the wrapper failed with `fish: Unknown command: --` for
every AI Agent command under fish. The `--` was unnecessary since
fish's `eval` has no options to terminate.
Fixes#721
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Codex follow-up review on PR #720
Two issues surfaced by Codex's post-merge review of PR #720:
P1 — useAutoSync.ts: startup retry exhaustion wedged auto-sync.
The retry effect previously returned at `attempt >= 4` without
opening `remoteCheckDoneRef`. A session with persistent inspect
failures (long network outage, provider rate-limit loop) left
auto-sync silently disabled for the rest of the session until
restart or provider/unlock transition. After exhaustion, open the
gate: the specific dangers we gate-closed against (empty-push,
partial-apply push) are now covered by independent guards
(`hasMeaningfulSyncData`, the apply-in-progress sentinel, and
`checkProviderConflict`'s inspect-failure throw at upload time).
This matches manual sync's existing semantic rather than silently
strict-gating auto-sync.
P2 — CloudSyncSettings.tsx: restore buttons were per-row disabled,
not globally. A user could click Row A, then Row B while A was
still applying — two concurrent `applyProtectedSyncPayload` calls
in the same window. `withRestoreBarrier` serializes across windows
but NOT same-window re-entry, so the second restore's
sentinel-clear could mask a still-partial first apply. Disable
every restore button while any restore is in flight.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: keep auto-sync gate closed on retry exhaust; open on manual sync
Codex's re-review of PR #723 correctly flagged that opening the
auto-sync gate after startup retry exhaustion reintroduces the
destructive-clobber path the gate was supposed to prevent. Concrete
scenario: local vault is partially lost (non-empty, just missing
entries), remote has not changed since our last anchor, user edits a
field after a long outage → auto-sync pushes the partially-lost
vault over the intact remote. `checkProviderConflict` doesn't catch
this (anchor matches), `hasMeaningfulSyncData` doesn't catch this
(non-empty), and the empty-vault prompt doesn't fire.
Revert the retry-exhaust gate-open. The gate now stays closed until
either:
1. A startup `checkRemoteVersion` succeeds (normal path), OR
2. A `syncNow` completes successfully. A manual sync from Settings
implicitly runs per-provider `checkProviderConflict` — the same
inspect the startup path would have done — so a successful
manual sync is equivalent to a successful startup reconciliation
from the gate's point of view and opens the gate for the rest
of the session.
This preserves Codex's safety ask (no auto-push without a confirmed
remote state) while giving the user a clear escape hatch (manual
sync) that doesn't require a restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add stable CSS hooks to tab components (#714)
Expose stable attributes on every tab-like element so custom CSS can
target them reliably without chaining utility-class selectors or
relying on inline-style substring matches:
- data-tab-id: already present on session/workspace/logView/sftp tabs;
now also added to the side-panel buttons (sftp/scripts/theme/ai)
in TerminalLayer.tsx.
- data-tab-type: session | workspace | logView | sftp | sidepanel,
lets a selector target one tab family without matching the rest.
- data-state: active | inactive, mirroring Radix Tabs' convention so
users who already style Settings tabs can reuse the same idiom.
- .netcatty-tab class: a single, scope-free hook for "every tab,
anywhere" — pairs with data-state="active" for the common "style
the selected tab" recipe.
No visual changes. The existing inline-style / utility-class selectors
the issue reporter had to chain ([style*="var(--top-tabs-active-bg"],
.app-no-drag.relative.h-7.px-3, etc.) keep working, so no breakage
for people who've already written custom CSS.
Custom CSS can now be written as:
.netcatty-tab[data-state="active"] { ... }
[data-tab-type="sftp"][data-state="active"] { ... }
[data-tab-id="ai"][data-state="active"] { ... }
Closes#714
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add CSS hooks to the root Vaults/SFTP tabs (#714)
The fixed-left root tabs ("Vaults" and "SFTP") in TopTabs.tsx were
missed in the first pass — they don't go through the session /
workspace / logView branches, so their div rendered without the new
data-tab-id / data-tab-type / data-state attributes or the
.netcatty-tab class.
Add them so custom CSS can target the whole root tab row the same
way:
[data-tab-type="root"][data-state="active"] { ... }
[data-tab-id="vault"] { ... }
[data-tab-id="sftp"] { ... }
No visual change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: harden sync overwrite recovery
* refactor: separate backup retention settings
* refactor: align backup retention controls
* refactor: simplify backup retention card
* fix: address PR #720 deep-review findings
- Close the cross-window restore race by holding a time-bounded barrier
in localStorage during every destructive apply; useAutoSync skips
pushes while it's set, preventing a pre-restore snapshot from
clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
actually reach the cloud instead of living only on the device that
ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
sanitized version strings, strict maxCount, concurrent-call mutex,
monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
it reflects what's being replaced rather than what was imported.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address PR #720 follow-up review findings
Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address second-pass review findings on PR #720
- Hold version-change stamp when payload is non-meaningful (covers the
startup vault-rehydrate race where a transient empty snapshot would
permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
(matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
subtle; createSyncedFileSignature returns null, forcing the
unreadable-remote → three-way-merge path instead of a weak
length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
future refactors don't silently break the UI reload path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address third-pass review findings on PR #720
Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:
- replace setTimeout(0) post-merge round-trip with a direct
syncAllProviders call using the already-computed merged payload,
removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
cannot drift on restore-barrier / protective-backup handling
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address deep-review findings on PR #720
Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:
- I1: fsync the protective backup file AND its directory before the
rename completes, so a system crash between backup creation and the
restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
localStorage writes in applySyncPayload. A crash mid-apply now
surfaces on the next startup (toast + refuse auto-push) instead of
silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
startup inspect validated cleanly. Add a bounded exponential-backoff
retry so a transient inspect failure self-heals instead of wedging
auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
inside uploadToProvider. A renderer crash between the two writes
now degrades to "stale anchor forces re-inspect on next run," which
re-merges against the fresh base — eliminating the silent
base-drift window where a 3rd-device race could misclassify
entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
every mutation; useLocalVaultBackups subscribes so protective
backups created from the main window show up in the Settings
backup list without manual refresh.
- I4: update PR description + code comment to match the actual
(safer) design: auto-sync gate opens on vault init, with
hasMeaningfulSyncData + restore barrier preventing empty-push; the
version-change backup is best-effort and retries as data hydrates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: serialize startup checkRemoteVersion and stabilize its deps
Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.
Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: release in-flight lock on no-connected-provider early return
Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to the trailing-show fix. Codex review on #718 flagged that
`focusMainWindow()` in main.cjs (called from `app.on("second-instance")`
and as the fallback path of `app.on("activate")`) still calls
`win.show()/focus()` without cancelling any in-flight close-to-tray
pending hide. A user who closes a fullscreen window to tray and then
relaunches the app via a second instance would see the window briefly
reappear and get hidden again when `leave-full-screen` lands.
Add `clearPendingFullscreenHide(win)` at the top of `focusMainWindow()`
so every reopen entry point (dock click, second-instance, activate
fallback) cancels the pending hide before showing the window.
The previous fix (dropping the show cancellation listener) still left
close-to-tray on a fullscreen mac window with a window-pops-back bug.
Reproduced with main-process logging on macOS 26:
T+0ms handleWindowClose + setFullScreen(false) + pending armed
T+56ms win.hide (internal, from setFullScreen false)
T+106ms our polling hid the window (isFullScreen() returned false)
T+591ms leave-full-screen arrives (animation actually done)
T+603ms win.show (macOS trailing event, finalizing space transition)
Two realisations:
1. isFullScreen() flips to false BEFORE the animation is visually
complete. Polling it and calling win.hide() at that moment caused
the pop-back (macOS undoes the hide when the animation finishes).
2. Even without (1), macOS emits a trailing `show` event ~12ms after
leave-full-screen. Any prior hide gets reversed by that show.
New strategy in hideWindowRespectingMacFullscreen:
- Do not hide from the polling timer; use polling only as a watchdog
that gives up after 5s without leave-full-screen (forces the leave
path anyway so at least the tray-hide is attempted).
- On leave-full-screen, arm a `once("show")` listener plus a 300ms
fallback timer. Whichever fires first runs the hide. This way the
hide lands on top of macOS's trailing show, so the show cannot
undo it.
- clearPendingFullscreenHide teardown now covers the new timer and
the trailing-show listener, so every cancel entry point stays
correct.
Tests rewritten to match the new state machine (no more poll-based
hide): one for the happy path, one for the trailing-show fallback,
one for the watchdog. All 11 tests pass.
macOS emits a `show` event on the BrowserWindow internally while the
native fullscreen exit animation lands the window back in its home
Space. PR #717's defensive `show` listener in
hideWindowRespectingMacFullscreen treated that as user intent and
cleared the pending hide, so clicking the red close button on a
fullscreen window left it visible on screen instead of going to the
tray.
Remove the `show` listener entirely. The other paths that legitimately
"bring the window back" during the exit animation (openMainWindow,
toggleWindowVisibility, setCloseToTray(false), the tray "Open Main
Window" menu) already call clearPendingFullscreenHide explicitly, so
the listener was only ever catching the internal transition emit.
Also wire app.on("activate") in main.cjs to call
clearPendingFullscreenHide so a dock-click during the exit animation
correctly cancels the pending hide as user intent.
Update the existing regression test to assert the new behavior
(`show` does not cancel; leave-full-screen still does), and add a
new test covering the app-activate path.
Adds a new terminal action that pastes the terminal's current selection
at the cursor without going through the system clipboard — the equivalent
of X11 PRIMARY-selection paste. Default shortcut: ⌘ + Shift + X / Ctrl + Shift + X.
Also surfaces the action in the terminal right-click menu, disabled when
there is no selection. Does not change middle-click paste behavior.
Closes#637
- persist drafts, panel views, and active sessions per terminal/workspace scope
- restore scoped AI session selection on reconnect and cold mount
- prefer unsent drafts over implicit history fallback
- avoid redundant active session map rewrites during scoped cleanup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.
Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).
This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.
Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.
Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.
Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
claude-agent-acp now resolves auth purely from its own CLI config / login
state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
ProviderConfig import from managedAgents.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.
The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.
Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract fail-loud check to shared getCodexCustomConfigPreflightError so
the list-models handler (aiBridge.cjs:2149) enforces the same up-front
error as the stream handler. Previously a user whose config.toml
env_key was unexported would get the targeted message on chat send but
a generic "Missing env var" from model-list probes (once the probe was
rewired for Codex in a future change).
- Wire Settings "Refresh Status" to also invalidate the shell-env cache.
New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration
now accepts an optional { refreshShellEnv } flag; the button passes it
so a user who just exported OPENROUTER_API_KEY in their rc file can
click Refresh instead of having to restart netcatty.
- Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts)
so renderer TS actually sees the field instead of needing a cast.
- DRY the 360 magic number in ChatInput: extract
MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the
left-clamp math so the two can't drift.
- Move codexCustomConfigResolved useState declaration next to its
companion codexConfigModel, above the effect that invokes its setter,
and drop the duplicate declaration further down. Pure code-organization
cleanup but removes a use-before-declaration nit.
No functional changes beyond the fail-loud parity and the refresh-shell-env
path. ACP behavior when authMethodId is omitted still requires a
real-world OpenRouter config.toml validation, which the user is running.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Round of fixes driven by two parallel reviewers:
- i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal
dollar-sign) but the replace call passed '{envKey}', so the warning
displayed a raw "${envKey}" instead of the real env var name. Align on
the codebase-standard {envKey} form.
- Fingerprint now folds the hash of the actual auth material (P1).
readCodexCustomProviderConfig computes a sha256 over the hardcoded
api_key or the resolved env_key value and returns authHash. The ACP
provider-reuse fingerprint includes it, so rotating the key in
~/.zshrc + restarting netcatty (which refreshes shellEnv) now
invalidates the cached provider instance instead of keeping the stale
key alive. Raw value never crosses the IPC boundary — we only send
the hex digest.
- Fail loud when config.toml's env_key isn't exported (P1). Previously
we'd sail into spawn and let codex-acp fail mid-request with a cryptic
"Missing environment variable". Now the stream handler rejects up
front with a targeted error naming the missing variable and pointing
at ~/.zshrc.
- TOML parser: basic-string escape tracking (P1). findUnquotedHash now
tracks an explicit `escaped` flag (and only honors escapes inside
double-quoted strings, since literal single-quoted strings don't), so
values like "C:\\path\\" close correctly instead of consuming the
trailing `#` as part of the string.
- TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend
one and the first-key regex would silently fail to match, dropping
everything before the first section header.
- Picker correctness when config.toml lacks a `model` field (P1).
Instead of silently falling back to CODEX_MODEL_PRESETS (stock
OpenAI IDs the user's custom endpoint can't serve), show an empty
list so the picker disables. Track codexCustomConfigResolved so we
distinguish "still loading" from "not a custom-config session" and
only clear the preset list once the integration probe confirmed
connected_custom_config.
- Logout handler isConnected also considers connected_custom_config
(P2 consistency), matching get-integration.
- Model picker popover clamps its left position so max-w-[360px] can't
push it past the right edge of a narrow AI side panel (P2).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex
request without a netcatty-managed API key. That helper spawns a fresh
codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json
to be valid — which it never is for users who only have a custom
model_provider set up in ~/.codex/config.toml. The validation failed,
the main window got "Codex ChatGPT login is stale or invalid. Reconnect
Codex in Settings" over the error channel, and the UI flipped to the
login prompt — exactly the flow the config.toml path is meant to skip.
Move readCodexCustomProviderConfig up so we compute it before the
validation gate, and only run the ChatGPT validation when there's
neither a netcatty-managed API key nor a detected config.toml custom
provider. The rest of the spawn path already omits authMethodId for
the custom-config case, so codex-acp connects directly with the shell
env and config.toml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues the user flagged with the previous round:
1. Probing codex-acp for available models returned the stock ChatGPT
catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active
provider. For a user with a custom model_provider in
~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless
on their endpoint. Roll back the managed-Codex probe hook and go
back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path.
2. The fixed w-[300px] popover left empty space on the right whenever
the longest row was narrower than 300px.
Instead of the probe, teach readCodexCustomProviderConfig to also
return the top-level `model` from config.toml and expose it on the
integration response. In AIChatSidePanel, call aiCodexGetIntegration
when Codex is the active agent and, if customConfig.model is present,
override agentModelPresets with a single-entry list pinned to that
model. Otherwise fall back to the static presets as before — so
ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while
custom-config users see just the model their provider is actually
pinned to.
Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]`
so it hugs content (great for short single-model lists) while still
capping very long rows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The picker label was being derived by splitting selectedModelId on the
first '/'. That works for Codex's ChatGPT-preset format
("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks
for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"):
selectedBaseModelId became "qwen", which doesn't match any preset, so
selectedPreset fell back to undefined and the chip displayed the
unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead
of the actually selected Codex model.
Replace the naive split with a two-step lookup: first try a direct id
match; only if that fails, look for a preset whose declared
thinkingLevels make "${preset.id}/${level}" equal to selectedModelId,
and derive the thinking segment from that. Model ids that happen to
contain '/' now round-trip correctly through the picker.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
codex-acp's provider descriptions can be paragraphs ("Latest frontier
model with improvements across a wide range of capabilities..."), which
made each row of the picker feel bloated. The model id and (thinking
sub-menu's) thinking level already convey the relevant distinction —
drop the description render entirely. Keeps the dropdown tight regardless
of how verbose the upstream model catalog is.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horizontal layout + truncate clipped too much of codex-acp's longer
descriptions ("Latest frontier model with improvements across a..." →
"Latest frontier model w..."). Reorganize each option as
checkmark | name-on-top, wrapped description below | chevron, so the
full description is readable across two lines without pushing the
popover width out. Fix popover to w-[300px] for a consistent column
width. Checkmark and chevron anchor to the first text line (self-start
with small top offset) so they stay visually aligned with the name
when the description wraps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With dynamic models now pulled from codex-acp, preset descriptions can be
arbitrarily long ("Latest frontier model with improvements across a..."
from OpenAI's public model list). The popover had whitespace-nowrap on
each option and no max-w on the container, so long descriptions pushed
the dropdown off-screen.
Cap the popover at max-w-[360px], add min-w-0 + truncate to the name
span so flex children can actually shrink, and cap the description span
at max-w-[160px] with truncate so it ellipses rather than expanding the
row. ChevronRight gets shrink-0 so it can't be pushed out of view.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent,
so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of
OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct
for the stock ChatGPT/OpenAI path. When the user has a custom
model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.),
none of those IDs exist on their endpoint and the model picker is useless.
Extend the condition to also trigger the aiAcpListModels probe for the
Codex managed agent (detected via matchesManagedAgentConfig). The probe
launches codex-acp the same way a real session does, so it now also goes
through getCodexAuthOverride and respects the user's config.toml — and
whatever availableModels codex-acp returns (typically at least the
`model` field from config.toml) shows up in the picker. Claude keeps its
curated presets to avoid regressing that path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first pass required both a custom model_provider in ~/.codex/config.toml
AND the referenced env_key to already be present in the shell environment.
If a user had the config file set up but hadn't (yet) exported the key in
their shell, detection returned null and the UI fell back to "Not
connected" + "Connect ChatGPT" — which is the exact flow they were trying
to avoid.
The config.toml is a strong enough signal of intent on its own. Keep the
integration in the connected_custom_config state regardless of env_key
availability, but expose envKeyPresent on the response so the UI can
explicitly warn "Warning: $MY_KEY is not set in your shell — export it".
Status label and color also flip to amber ("Custom config detected — env
var missing") so the state is easy to spot without dropping back to the
login prompt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users who hand-configure ~/.codex/config.toml with a custom model_provider
and matching [model_providers.<name>] entry are fully functional from the
Codex CLI, but netcatty only looked at codex login status — which reports
on ~/.codex/auth.json alone — and would therefore push them into the
ChatGPT login flow even though the CLI works for them.
Add a minimal TOML parser for the narrow subset we need (top-level keys
plus [model_providers.<name>] string tables), and readCodexCustomProvider
Config() to detect a usable custom-provider setup: an active model_provider
that isn't the built-in openai preset, pointing at a provider entry whose
env_key is set in the shell env (or api_key is hardcoded).
Surface this as a new integration state "connected_custom_config", add a
customConfig summary on the IPC response, and tweak the Codex settings
card so it shows the custom-provider name, hides the Connect ChatGPT
button, and drops the stale "OpenAI-compatible provider" hint when this
path is active.
At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass
authMethodId: "chatgpt" when we truly have no other option. When a
netcatty-managed API key is present we still use "codex-api-key"; when the
user has a custom config we omit authMethodId entirely so codex-acp
resolves auth from the shell env / config.toml itself. Fold the detected
custom config (provider name, base url, env key presence) into the
provider reuse fingerprint so edits to config.toml invalidate cached ACP
instances.
Fixes the Codex half of #677 for users who skip Settings → AI providers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Codex P1 on #701: the nested term.write callbacks in handleRetry
kept a captured reference to startNewSession. If the user hit Cancel or
closed the tab while those writes were still queued, cleanupSession ran
first but the callback could still fire afterwards — opening a backend
session with no owning UI (a ghost connection that nothing would tear
down).
Introduce retryTokenRef. handleRetry stamps a fresh Symbol, captures it,
and the chained callbacks verify the token (plus termRef identity) is
still current before proceeding. Invalidate the token from every path
that ends the retry intent: handleCancelConnect, handleCloseDisconnected
Session, teardown. A subsequent handleRetry naturally invalidates the
prior one by overwriting the ref, so rapid double-clicks are also safe.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Codex P1 on #701: term.write is asynchronous, but handleRetry was
calling sessionStarters.start* synchronously right after scheduling
the soft-reset write. On fast reconnect paths (local and serial
especially, where the backend has no network round-trip), the new
session's first output bytes can reach xterm before the \x1b[!p...\x1b[H
reset has been applied. That means the reset/home runs mid-stream of
the first prompt, repositioning the cursor or flipping modes partway
through the shell's init and producing intermittent corrupted first
screens.
Extract the protocol dispatch into startNewSession and pass it as the
callback of the second term.write, so the new session only starts
once every preparation byte (alt-screen exit, viewport preserve,
DECSTR, xterm mode disables, cursor home) has actually been applied
to the terminal state. State updates that only drive the UI overlay
(status, progress logs) stay synchronous so users see "connecting..."
immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses two Codex findings on #701:
P1 (alt-screen ordering) — preserveTerminalViewportInScrollback only
operates on the normal buffer. If the user disconnected while inside
vim/less/top, the alt buffer was active, preserve was a no-op, and
when \x1b[?1049l later switched back to normal, the new session wrote
over still-visible pre-disconnect content instead of a cleared
viewport. Send \x1b[?1049l first, then wait for the write to flush
(via xterm's write callback) before calling preserve, so it always
runs on the normal buffer.
P2 (DECCKM / keypad / other VT220 modes) — the previous reset sequence
only disabled xterm extensions (mouse tracking, bracketed paste) and
touched SGR / cursor visibility. Full-screen apps commonly enable
DECCKM (application cursor keys) and keypad application mode; those
would leak into the new session and break arrow-key history
navigation and numeric keypad input. Use DECSTR (\x1b[!p) — soft
terminal reset — to reset DECCKM, keypad mode, SGR, insert/replace,
origin mode, and cursor visibility in one shot without clearing the
buffer. Keep explicit disables for the xterm-specific modes DECSTR
doesn't cover.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Codex P2 on #701: handleRetry previously removed term.reset() but
the replacement escape sequence didn't disable bracketed paste (DECSET
2004). If the disconnected session had turned it on, term.modes
.bracketedPasteMode stayed true into the next connection; the paste
and snippet paths in createXTermRuntime keep wrapping input with
\x1b[200~ ... \x1b[201~ markers. When the new session hasn't itself
enabled bracketed paste, the shell echoes those markers as literal
text and mangles pastes.
Add \x1b[?2004l to the retry reset sequence so bracketed-paste state
starts off for the new session; the new shell's init will re-enable
it normally if it wants.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each session starter (startSSH / startTelnet / startMosh / startLocal)
called term.clear() as its first step. In xterm.js, clear() wipes the
entire buffer including scrollback. On initial connect this is harmless
(the buffer is already empty), but on retry it undoes the viewport
preservation that handleRetry just performed — so #695 remained broken
for any protocol that went through these starters (i.e. all of them).
The clear call served no purpose: xterm mounts with an empty buffer and
nothing writes to it before the starter runs. Remove the four
try/catch(term.clear()) blocks so handleRetry's
preserveTerminalViewportInScrollback actually sticks across reconnect
on SSH reboots, telnet drops, mosh/local respawns, etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On disconnect + retry, handleRetry previously called term.reset(), which
wipes both the visible screen and the scrollback history — so users lost
every bit of context from the previous session the moment they hit
"Start Over".
Push the current viewport into scrollback via the existing
preserveTerminalViewportInScrollback utility, then explicitly disable
the modes we actually care about not leaking across sessions (mouse
tracking 1000/1002/1003/1006, alt-screen 1049, SGR attributes, hidden
cursor) and home the cursor. This keeps the full scrollback intact so
users can scroll up to read everything from before the disconnect,
while still preventing stale escape-sequence state from bleeding into
the new session.
Fixes#695
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.
Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per Codex P1 review on #700: when showSftpTab is off, executeHotkeyAction
still built allTabs as ['vault', 'sftp', ...orderedTabs]. nextTab from
Vault would land on hidden 'sftp', the showSftpTab effect then redirected
back to 'vault', trapping tab cycling so Ctrl/Cmd+Tab could not advance
into terminal tabs. Number shortcuts (Ctrl+1..9) were also shifted, e.g.
tab 2 resolved to hidden SFTP and ping-ponged back to Vault.
Build allTabs conditionally so 'sftp' is only in the cycle when the tab
is visible. This keeps nextTab/prevTab/switchToTab consistent with what
the user sees in the top tab bar.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Show SFTP tab" toggle in Settings → Appearance (under the
Vault section) that controls visibility of the standalone SFTP view
in the top tab bar. When disabled:
- The SFTP tab is removed from the top tab strip.
- The openSftp hotkey (Ctrl+Shift+O / ⌘⇧O) becomes a no-op.
- If the user is currently on the SFTP tab, the active tab auto-
switches to Vaults.
The in-session SFTP side panel (opened from the terminal toolbar) is
unaffected — that is the surface users keep when they hide the
top-level tab.
Setting persists via localStorage, syncs across windows, and is
included in the cloud SyncPayload alongside the existing Vault
visibility toggles (showRecentHosts,
showOnlyUngroupedHostsInRoot). Default: on.
Addresses the first ask in #690.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the documented default was Ctrl+Shift+F on PC, but a
hardcoded handler always captured plain Ctrl+F regardless of the
configured binding — so the effective default users experienced was
Ctrl+F. Now that the hardcoded handler is removed, align the declared
default with that historical behavior so existing users don't lose the
shortcut they were used to. Users who need plain Ctrl+F for the shell
(e.g. zsh forward-char) can remap or disable it in Settings → Shortcuts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The xterm custom key event handler intercepted plain Ctrl+F / Cmd+F to
open terminal search, ignoring the user's configured keybinding scheme.
This conflicted with zsh's forward-char (Ctrl+F) and gave users no way
to disable it via the Shortcuts settings tab.
The configurable keybinding system below already routes the
searchTerminal action via checkAppShortcut, with defaults of
Ctrl+Shift+F (PC) and Cmd+F (Mac). Dropping the hardcoded branch
lets the user's settings take effect. Also remove the stale
"(Ctrl+F)" label from the toolbar tooltip since the shortcut is
configurable and the default on PC is Ctrl+Shift+F.
Fixes#694
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ACP provider reuse gate only computed authFingerprint for Codex,
leaving it null for Claude. Changing the configured provider or base
URL mid-session would keep reusing the stale provider instance.
Now Claude computes an authFingerprint from apiKey + baseURL, so
changing either value invalidates the cached provider and forces
recreation with the new credentials/endpoint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A generic custom provider (OpenAI-compatible) could be selected for
Claude, passing wrong credentials. Now we prefer an explicit anthropic
provider and only fall back to a custom provider when it has a baseURL
configured (indicating intentional Anthropic-compatible gateway use).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex reads OPENAI_BASE_URL to connect to custom API endpoints.
Without this, users with a custom baseURL on their OpenAI provider
config would still hit the default api.openai.com endpoint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The renderer only resolved OpenAI providers (for Codex) when passing
provider IDs to the main process. Claude agent was never matched, so
no API key was injected. Additionally, the main process only injected
CODEX_API_KEY — never ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL.
Changes:
- Renderer now resolves anthropic/custom provider for Claude agent,
openai provider for Codex agent (via matchesManagedAgentConfig)
- Main process injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL into
claude-agent-acp env when a provider is configured, across all three
ACP provider creation paths (list-models, stream, fallback)
This enables users who configure an Anthropic provider with a custom
base URL (e.g. CC Switch proxy) to use Claude Code without being
redirected to the official OAuth flow.
Closes#677
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
xterm.js treats scrollback=0 as "no scrollback buffer", which makes
hasScrollback return false and converts wheel events into arrow-key
sequences. The UI uses 0 to mean "no limit", so map it to 999999
before passing to xterm.js.
Closes#689
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When followAppTerminalTheme is on, all terminals should use the
UI-matched theme — but three resolution points were still checking
per-host overrides:
1. App.tsx resolveTheme() in the activeTerminalTheme computation
2. Terminal.tsx effectiveTheme computation
3. TerminalLayer.tsx focusedThemeId computation
Added followAppTerminalTheme prop flowing from App → TerminalLayer
→ Terminal. When the flag is true, per-host theme resolution is
bypassed so all terminals consistently match the app chrome.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ToggleRow is a locally-defined component in HostDetailsPanel and
GroupDetailsPanel — it is NOT exported or available in the terminal
settings tab. Using it caused a white-screen crash. Replaced with
the existing SettingRow + Toggle pattern that's already used
throughout the terminal settings tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- App.tsx: remove unused followAppTerminalTheme/setFollowAppTerminalTheme
from destructuring (they flow through settings object, not App props)
- createTerminalSessionStarters.ts: remove dead usedKey/usedPassword
assignments left over from PR #680 which changed runDistroDetection
to use the existing session's connection instead of auth credentials
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Follow mode defaulted ON when the storage key was missing, which
is true for ALL existing users after upgrade (not just fresh
installs). Now checks whether a terminal theme was already stored —
if so, this is an upgrade and we default OFF to preserve the user's
manual choice. Only genuinely fresh installs (no terminal theme in
storage) default to ON.
P2: The follow-theme persist effect now calls notifySettingsChanged
and a matching branch in the cross-window storage event handler
syncs the toggle state across windows, matching the pattern used by
all other terminal settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When enabled (default for new users), the terminal theme automatically
switches to match the active app UI theme — so the terminal background
blends seamlessly with the app chrome, regardless of which UI theme
preset the user picks (Snow, Midnight, Forest, etc.).
## New terminal themes (14)
Each built-in UI theme preset now has a corresponding terminal theme
with an exactly matching background color:
Light: ui-snow, ui-pure-white, ui-ivory, ui-mist, ui-mint, ui-sand,
ui-lavender — ANSI palette based on netcatty-light with per-theme
cursor colors that complement the UI accent.
Dark: ui-pure-black, ui-midnight, ui-deep-blue, ui-vscode,
ui-graphite, ui-obsidian, ui-forest — ANSI palette based on
netcatty-dark with accent-matched cursors and selections.
## "Follow Application Theme" setting
- New toggle in Settings → Terminal → Theme section
- Default ON for new users, persisted in localStorage
- When ON: terminal theme auto-derived from the active UI theme via
a mapping table in domain/terminalAppearance.ts
- When OFF: manual theme selector shown (existing behavior)
- Switching the app between light/dark (or changing the UI theme
preset) instantly updates the terminal theme
## Files changed (9)
- terminalThemes.ts: +14 theme definitions
- terminalAppearance.ts: UI→terminal mapping table +
getTerminalThemeForUiTheme()
- useSettingsState.ts: followAppTerminalTheme state + persist +
currentTerminalTheme derivation
- storageKeys.ts: new storage key
- SettingsTerminalTab.tsx: toggle UI + conditional theme selector
- SettingsPage.tsx: pass new props
- App.tsx: destructure new state
- en.ts + zh-CN.ts: 2 new i18n keys
Closes#675
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On Windows, the built-in text editor produces CRLF line endings.
When saved to a Linux host via SFTP, the \r characters break shell
scripts ("command not found", syntax errors) because Linux treats
\r as part of the command.
Normalize \r\n → \n in writeSftp() before writing. LF is universally
supported — even Windows 10+ notepad handles LF-only files — so this
is safe for all target platforms.
Closes#681
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a `portable` target alongside the existing `nsis` installer for
Windows builds. The portable version produces a single .exe that
runs without installation — just download and double-click.
The artifact is named with a `-portable-` infix to distinguish it
from the installer in the release assets.
Closes#668
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add Gist revision history UI for vault restore (#679)
Adds a "History" button on the GitHub Gist provider card in
Settings → Sync & Cloud. Clicking it opens a modal that lists all
Gist revisions (newest first) and lets the user preview and restore
any historical version with one click.
## How it works
1. The GitHub API already returns a `history` array when fetching a
Gist (`GET /gists/{id}`). The existing `getGistHistory()` reads
this. A new `downloadGistRevision(sha)` function fetches a
specific revision via `GET /gists/{id}/{sha}`.
2. CloudSyncManager exposes `getGistRevisionHistory()` (metadata
only, no decryption) and `downloadGistRevision(sha)` (decrypt
+ return payload and preview counts).
3. useCloudSync threads both methods through to the UI.
4. CloudSyncSettings renders a three-state modal:
- **Loading**: spinner while fetching revision list
- **Revision list**: clickable rows with SHA prefix + date,
"Current" badge on the latest
- **Preview**: after clicking a revision, shows entity counts
(hosts, keys, snippets, identities) and a "Restore This
Version" button
5. Decryption uses the current master password. If the revision
was encrypted with a different password (user changed it since
then), a clear error message is shown instead of a crash.
## Changes
- `GitHubAdapter.ts`: add `downloadGistRevision()` standalone
function + `getHistory()` / `downloadRevision()` class methods
- `CloudSyncManager.ts`: add `getGistRevisionHistory()` and
`downloadGistRevision(sha)` with decrypt + preview
- `useCloudSync.ts`: expose both methods
- `CloudSyncSettings.tsx`: add `extraActions` slot to ProviderCard,
render "History" button on GitHub card, revision history modal
with list → preview → restore flow
- `en.ts` + `zh-CN.ts`: 18 new i18n keys for the modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use getConnectedAdapter and lazy gist discovery for history APIs
P1: CloudSyncManager's history methods accessed this.adapters directly
instead of getConnectedAdapter(), which lazily initializes adapters.
After an app restart the adapter map is empty even though the provider
is persisted as connected, making history fail until another sync
path initializes it.
P2: GitHubAdapter.getHistory() and downloadRevision() bailed early
when gistId was missing, unlike download() which calls findSyncGist()
to lazily discover it. Users whose gist was created after initial
setup would see no revisions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-2 codex review on PR #685
P1: Renamed cloudSync.history.* keys to cloudSync.revisionHistory.*
to avoid duplicate key collision with the existing "Sync History"
section title.
P2: Added getGistRevisionHistory and downloadGistRevision to the
CloudSyncHook type interface so the hook contract matches reality.
P2: Simplified decrypt error handling — any error from the decrypt
path now shows the friendly "cannot decrypt" message rather than
relying on fragile substring matching.
P2: Clear historyRevisions on each handleOpenHistory call so stale
data doesn't linger under error banners on retry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore correct i18n key for Sync History section title
The sed rename pass accidentally changed the Sync History panel
heading (line 1290) from cloudSync.history.title to
cloudSync.revisionHistory.title. Restored the original key so the
two sections have distinct titles. Also removed unused err parameter
in the catch block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent empty vault from overwriting cloud data on startup (#679)
Fixes a data-loss scenario where an empty local vault (caused by an
update, storage corruption, or import failure) silently overwrites
a non-empty cloud vault on startup via auto-sync.
The root cause is a startup timing race: the debounced auto-sync
effect (3s after data change) can fire before checkRemoteVersion
(1s delay + async download) completes its remote pull. When the
local vault is empty, this pushes an empty payload to the Gist,
permanently erasing the user's data.
Four complementary fixes:
A. Empty vault push guard (useAutoSync syncNow):
Auto-sync refuses to push a payload where hosts, keys, snippets,
and identities are ALL empty. Manual sync from Settings is still
allowed for the rare case where the user intentionally emptied
everything. Prevents the most dangerous path.
B. Skip redundant post-merge push (useAutoSync checkRemoteVersion):
After applying a three-way merge result from the remote, set
skipNextSyncRef so the data-change effect does not immediately
re-upload the same payload. Removes one unnecessary API call per
startup sync.
C. Gate auto-sync on remote check completion (useAutoSync effect):
Added remoteCheckDoneRef — the debounced auto-sync effect will
not fire until checkRemoteVersion has completed (success or
failure). This closes the timing window entirely: an empty vault
can no longer race ahead of the remote pull.
D. Empty-vault-vs-cloud confirmation dialog (App.tsx + useAutoSync):
When checkRemoteVersion detects local is empty but cloud has
data, it pauses and shows a root-level dialog with two options:
- "Restore from Cloud" (recommended) — applies the remote payload
- "Keep Empty" — starts fresh with an empty vault
The dialog blocks the sync flow via a Promise that resolves when
the user picks an option. This gives users explicit control over
a situation that previously happened silently behind their backs.
Also adds en + zh-CN i18n strings for the new dialog and toast
messages.
Closes#679
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address codex review on PR #683
P1-1: Unified isPayloadEffectivelyEmpty helper covering all synced
entity arrays (hosts, keys, snippets, identities, customGroups,
snippetPackages, portForwardingRules, knownHosts, groupConfigs).
Replaces the three inline checks in syncNow and checkRemoteVersion
that only covered hosts/keys/snippets/identities.
P1-2: Replaced hand-rolled overlay div with the project's existing
Dialog/DialogContent/DialogHeader/DialogFooter components. This adds
role="dialog", aria-modal, focus trap, and ESC-key dismiss for free.
Used lucide-react AlertTriangle/Download/Trash2 icons instead of
inline SVGs.
P2-1: Guard against double-resolve in resolveEmptyVaultConflict by
nulling the ref immediately on first call.
P2-2: Replaced hardcoded "N hosts, N keys, N snippets" with an i18n
key using interpolation (cloudSummary) so the count text is properly
translated in zh-CN.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-2 codex review on PR #683
P1: isPayloadEffectivelyEmpty now also checks the settings object.
A vault with only settings (e.g. custom theme, font size) and zero
hosts/keys/snippets is no longer treated as empty.
P1: Dialog accessibility — use hideCloseButton to remove the non-
functional close button, onEscapeKeyDown + onOpenChange prevent
dismiss (the user MUST choose an option), and wrap the description
in DialogDescription so aria-describedby is properly linked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use single-brace interpolation syntax for cloudSummary i18n key
The project's i18n system uses single-brace placeholders ({var}),
not double-brace ({{var}}). The double-brace syntax was rendering
as raw text instead of being interpolated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: pass legacyAlgorithms to port forwarding SSH connections (#678)
Port forwarding connections always used modern-only algorithms because
the legacyAlgorithms host setting was never threaded through to the
port forwarding bridge. When the jump server or target host runs an
older SSH implementation (e.g. OpenSSH 7.4) that only supports legacy
key exchange algorithms like diffie-hellman-group14-sha1, the
handshake fails with "Connection lost before handshake".
The SSH terminal path already handles this correctly via
buildAlgorithms(options.legacyAlgorithms) — the port forwarding path
was simply missing the same plumbing.
Changes:
- sshBridge.cjs: export buildAlgorithms so portForwardingBridge can
reuse it (avoids duplicating the algorithm list)
- portForwardingBridge.cjs: destructure legacyAlgorithms from the
payload, pass it to connectOpts.algorithms via buildAlgorithms(),
and thread it through to connectThroughChain for jump host
connections
- portForwardingService.ts: include host.legacyAlgorithms in the
startPortForward bridge call
Closes#678
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add legacyAlgorithms to PortForwardOptions type contract
Per Codex review: the new legacyAlgorithms field was being passed
in the startPortForward call but was not declared in the
PortForwardOptions interface in global.d.ts, causing a TS2353 type
error in strict type-checking environments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: auto-detect network devices from SSH banner and skip stats polling (#674)
Fixes rapid AAA session churn reported on Cisco/HPE/similar network
devices running Netcatty. The root cause was two separate polls that
both open fresh exec channels (each counted as its own AAA session on
many network devices):
- runDistroDetection() opens a brand new SSH connection every time a
host connects to run `cat /etc/os-release || uname -a`
- useServerStats polls `conn.exec(statsCommand)` every 5 seconds
Both commands fail on non-POSIX CLIs, but the channels still hit AAA.
This change avoids both by reading the SSH server identification
string that ssh2 already captures during the handshake
(`conn._remoteVer`). No extra network round-trips, zero additional
AAA entries.
## Changes
**sshBridge.cjs**
- Store `conn._remoteVer` on the session object at connect time as
`session.remoteSshVersion`
- New IPC handler `netcatty:ssh:remoteInfo` (`getSessionRemoteInfo`)
returning the captured SSH server software string
**preload.cjs / global.d.ts / useTerminalBackend.ts**
- Thread `getSessionRemoteInfo(sessionId)` through to the renderer
**domain/host.ts**
- `NETWORK_DEVICE_OPTIONS` constant listing the vendor IDs we can
recognize (cisco, juniper, huawei, hpe, mikrotik, fortinet,
paloalto, zyxel)
- `detectVendorFromSshVersion()` — pure function that parses an SSH
server software string and returns a vendor ID or ''. Pattern set
is sourced from Nmap nmap-service-probes (authoritative), the
ssh-audit software.py reference, and vendor docs; see code
comments for the exact matches used.
- `classifyDistroId()` returns `linux-like | network-device | other`
so features that require a POSIX shell can gate on the result.
**createTerminalSessionStarters.ts (runDistroDetection)**
- Before running the /etc/os-release probe, call
`getSessionRemoteInfo` on the already-connected session and feed
the banner into `detectVendorFromSshVersion`. If the vendor maps
to a known network device, emit the vendor ID via the existing
`onOsDetected` callback and skip the shell probe entirely. For
unknown or generic OpenSSH/Dropbear banners the existing behavior
is preserved.
**Terminal.tsx**
- `isSupportedOs` now derives from `classifyDistroId(effectiveDistro)`
combined with `host.deviceType !== 'network'`, so neither explicit
network-device hosts nor banner-detected ones trigger the stats
polling loop.
**useServerStats.ts**
- Add a consecutive-failure counter. After 3 consecutive failed
polls, stop the interval for this session (reset on disconnect /
sessionId change / settings toggle). This is the fallback for
hosts the banner classifier cannot identify (Juniper JUNOS,
Cisco NX-OS, Arista EOS — all present as plain `OpenSSH_*` but
do not support the POSIX stats pipeline).
**DistroAvatar.tsx / HostDetailsPanel.tsx**
- Add 8 network-device vendor icons (Cisco, Juniper, Huawei, HPE,
MikroTik, Fortinet, Palo Alto, ZyXEL) alongside the existing
Linux distro icons, with brand colors. Icons sourced from Simple
Icons (CC0) where available; HPE and ZyXEL use simple
abbreviation placeholders.
- Network device vendors are added to the manual distro override
dropdown so users can pin an icon even if their device has an
exotic banner we don't auto-detect.
**i18n**
- English + Chinese labels for the new vendor options in the
Host Details distro selector.
Closes#674
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: gate network-device detection on raw host.distro, not manual icon override
Per Codex review on PR #680: the stats-polling gate was passing
`host` through getEffectiveHostDistro() before classifying, which
honors the manual distro override (`distroMode: 'manual'` +
`manualDistro`). That meant a user who previously pinned an
"ubuntu" icon on a host that later gets banner-detected as Cisco
would still be classified as linux-like and keep generating the
AAA session flood #674 is meant to eliminate.
Separate display from gating:
- Display (DistroAvatar, host cards): keeps using
getEffectiveHostDistro so users can cosmetically override the
icon.
- Gating (useServerStats via Terminal.tsx isSupportedOs): reads
host.distro directly — the value populated by banner detection —
alongside the explicit host.deviceType flag. Manual icon choice
can no longer re-enable polling on a detected network device.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard distro detection against stale session timers
Per Codex review on PR #680: runDistroDetection is scheduled on a
600ms setTimeout after connection and also makes async calls of its
own. A quick disconnect + reconnect on the same session slot could
fire the old timer against the new session, reading host B's SSH
banner via getSessionRemoteInfo and writing host B's vendor onto
host A's distro field — wrong icon and wrong stats-polling state.
Follow the same pattern already used for the startup-command timer
in this file (scheduledSessionId captured at schedule time, checked
inside the timer). Capture `id` at schedule time, bail out if
ctx.sessionRef.current no longer matches, and re-check after every
async await inside runDistroDetection so that a reconnect during
the banner fetch or the os-release probe also bails cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address local codex review on PR #680
Addresses three issues found in a local Codex review pass after the
remote reviewer gate was flaky:
## P0 — session tokens instead of sessionId for stale-timer guard
The previous guard captured `id` returned from startSSHSession and
compared against `ctx.sessionRef.current` inside the setTimeout and
the async runDistroDetection. But the renderer passes
`sessionId: ctx.sessionId` into startSSHSession (see
createTerminalSessionStarters.ts:543), meaning a tab reuses the
SAME sessionId across disconnect+reconnect. The comparison
`T1 === T1` always passed, so the guard was a no-op.
Replaced with a module-level Map<sessionId, object> that stores the
live "connection token" for each sessionId slot. Each call to
startSSH mints a fresh `{}` token and overwrites the entry. Timers
and async continuations compare their captured token against the
current map value by reference — a reconnect replaces the map entry
with a new token, so stale callbacks bail cleanly.
## P1 — run os-release probe on the existing SSH connection
The fallback /etc/os-release probe used `execCommand` which creates
a brand-new SSHClient() on every call. On network devices that
present as plain `OpenSSH_*` and fall through to this step
(JUNOS, NX-OS, EOS) it added one extra full-auth AAA session log
entry per connect, in addition to the failing stats polls.
Added `getSessionDistroInfo(sessionId)` as a new IPC handler that
runs the same probe via `session.conn.exec()` — an exec channel on
the already-open connection, no new handshake. Plumbed through
preload.cjs, global.d.ts, and useTerminalBackend.ts.
runDistroDetection uses this instead of execCommand in the fallback
path, also removing the unused auth-credentials argument (we are no
longer opening a new connection, so no credentials are needed).
## P2.1 — don't re-arm timers after giving up
After the consecutive-failure counter trips, useServerStats cleared
the interval but a subsequent effect rerun (visibility change,
settings tweak, etc.) would schedule a fresh `setTimeout` and
`setInterval` that would just call the early-return path forever.
The scheduling block now checks `givenUpRef.current` before arming
either timer. The flag is still cleared on the normal disconnect /
sessionId-change reset path so a reconnect gets a fresh attempt.
## P2.2 — drop the ambiguous IPSSH-* → cisco mapping
Nmap's `match ssh m|^SSH-([\d.]+)-IPSSH-` line is labelled as
`Cisco/3com IPSSHd` — it cannot identify a specific vendor from the
banner alone. Mapping it to `cisco` would risk showing the wrong
vendor icon on a 3Com device. Removed the rule entirely and
documented why with a code comment; users with such devices can
still use the Host Details manual distro override.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address remaining gaps from local codex follow-up review
P0 gap — delete connection token on session exit. Previously the map
entry lingered after disconnect, so a very late-firing timer could
still pass the isConnectionTokenCurrent check even though the session
no longer existed. Functionally harmless (the IPC calls would fail)
but semantically wrong. Now connectionTokensBySessionId.delete() is
called in the onSessionExit handler.
P1 new — exec channel leak on timeout in getSessionDistroInfo. The
timeout branch resolved the promise but didn't close the stream, so
a hanging remote command would leave the exec channel open until the
SSH connection itself dropped. Added a settled guard (resolve-once)
and stream.close() on timeout.
P2.1 gap — givenUpRef not reset on sessionId change. The failure
counter reset only happened in the !isConnected branch of the main
effect, so a sessionId swap while still connected (rare, but
possible if the tab reconnects without toggling connected state)
would permanently suppress polling. Added a small dedicated effect
that resets both counters when sessionId changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent crash when clicking external links with no default browser (#663)
On systems like Tiny11 where no default browser is associated with
http/https URLs, shell.openExternal() rejects with Windows error 0x483
("No application is associated..."). The main process treated that
rejection as an unhandledRejection, which the global handler re-throws
as fatal, crashing the entire app.
Root cause: windowManager.cjs used `void shell?.openExternal?.(url)`
inside a try/catch, assuming the try would cover the call. `void` only
discards the returned Promise — it does not catch async rejections,
so when openExternal rejected, the error escaped as a floating
unhandledRejection.
The IPC handler in main.cjs (`netcatty:openExternal`) also awaited
shell.openExternal() without any try/catch. Electron's ipcMain.handle
forwards rejections to the renderer over IPC, but the renderer-side
fallback called `window.open()`, which re-entered the same buggy
windowManager path — and that is where the process actually died.
Changes:
- windowManager.cjs: attach an explicit `.catch` on the openExternal
Promise in both createExternalOnlyWindowOpenHandler and
createAppWindowOpenHandler so rejections cannot propagate.
- main.cjs: wrap the IPC handler in try/catch and return a structured
{ success, error } result instead of throwing. This lets the
renderer render an informative message.
- global.d.ts: update the openExternal return type to match.
- useApplicationBackend.ts: read the structured result and throw on
failure so callers can react; drop the now-redundant window.open()
fallback for the Electron branch (kept only for non-Electron envs).
- SettingsApplicationTab.tsx: show a friendly toast ("No default
browser configured — please set one in system settings") when
openExternal fails, instead of the previous silent failure.
- i18n: add en + zh-CN strings for the toast.
Closes#663
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: fall back to in-app browser window when system has no default browser
Instead of showing a toast when shell.openExternal() fails (e.g. Tiny11
with no default browser), open the URL in a minimal in-app BrowserWindow
so users can still read the linked page.
windowManager.cjs now exposes:
- openFallbackBrowser(url, opts): creates a stripped-down BrowserWindow
that loads the URL. No preload script (remote content must never
touch contextBridge), contextIsolation/nodeIntegration/sandbox all
set to safe defaults, and an isolated persist:netcatty-fallback-browser
session so cookies and storage do not leak into the main app.
Basic Alt+Left / Alt+Right / Ctrl-or-Cmd+R shortcuts for navigation
and reload.
- tryOpenExternalWithFallback(shell, url, opts): tries
shell.openExternal first; on rejection, falls back to
openFallbackBrowser. Returns { success, fallback?: "in-app-browser" }.
All three external-URL call paths now route through this helper:
- main.cjs netcatty:openExternal IPC handler
- createExternalOnlyWindowOpenHandler (popup blocker for child windows)
- createAppWindowOpenHandler (main/settings window window-open handler)
The renderer-side toast is retained as a last-resort for the rare case
that both system and in-app browsers fail (e.g. BrowserWindow creation
error). Copy updated to reflect the new behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve rejection semantics for failed external opens
Per Codex review on PR #676: returning { success, error } from
bridge.openExternal changed the contract from "reject on failure" to
"resolve with a failure object on failure", which silently broke
callers that rely on rejection to abort flows.
useCloudSync's OAuth path is the clearest example: it wraps
bridge.openExternal in a try/catch and rejects browserPromise inside
the catch. With the resolved-failure contract, that catch never fires,
so Promise.race([callbackPromise, browserPromise]) can hang
indefinitely when no browser is available.
Revert the contract:
- tryOpenExternalWithFallback resolves void on success (system browser
or in-app fallback) and throws on total failure
- main.cjs IPC handler awaits and lets rejections propagate
- global.d.ts openExternal is Promise<void> again
- useApplicationBackend just awaits — rejections propagate naturally
- SettingsApplicationTab's existing try/catch + toast continues to
work as before
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: propagate fallback browser loadURL failures
Per Codex P2: openFallbackBrowser swallowed loadURL rejections by
attaching a .catch that only logged, so any caller using
tryOpenExternalWithFallback as a success signal saw an opened window
as success even when the page failed to load. OAuth flows would then
wait for the downstream callback timeout instead of canceling early
on malformed or unreachable URLs.
openFallbackBrowser now returns { window, loaded } where `loaded` is
the raw loadURL Promise, and tryOpenExternalWithFallback awaits it in
the fallback path. On initial load failure, the broken window is
closed and the original shell.openExternal error is re-thrown.
The internal popup handler inside the fallback window keeps its
fire-and-forget behavior (it must return synchronously) but now
explicitly catches the loaded rejection to avoid unhandledRejection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The main openSftp() connection path was building ssh2 connect options
without setting keepaliveInterval at all, so no SSH-level keepalive
packets were sent on the SFTP channel. When the SFTP panel sits idle
(the common case while a user browses files), NAT/firewall state
tables reap the idle TCP connection after ~30-60s, causing the panel
to disconnect while the SSH terminal next to it — which has its own
keepalive config via sshBridge — stays connected. That matches the
exact symptom reported in #669.
Default to a 10s keepalive interval, matching the existing SFTP jump
host path (sftpBridge.cjs:466-467). Honor an explicitly configured
positive options.keepaliveInterval (in seconds) if one is passed in,
so the frontend can thread the user setting through later without
another bridge change.
Closes#669
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: pin toolbar above content on KeychainManager page
* fix: apply panel offset to outer wrapper so toolbar is not covered
The aside panel is rendered as an absolute overlay (right-0, w-[380px]),
so any container covered by the overlay needs mr-[380px] to avoid
having its right-side controls obscured. Previously only the inner
scroll area had the offset, which left the toolbar at full width —
its right-side controls (view-mode dropdown, etc.) would be covered
by the panel and become unclickable when it opened.
Move both the margin and the transition to the outer flex wrapper so
the toolbar and the scroll area shift together when the panel opens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve file permissions when saving edited file via SFTP (#665)
ssh2-sftp-client's put() overwrites existing files with the server's
default mode (typically 0o666 after umask), so a 0o755 file edited
through the built-in text editor would silently become 0o666 after
save.
Stat the file before writing to capture its existing mode, then
chmod it back to that mode after put() completes. For new files,
stat fails and we fall through to let the server apply defaults,
preserving existing behavior for file creation.
Closes#665
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: also preserve setuid/setgid/sticky bits when restoring mode
Use 0o7777 mask instead of 0o777 so special permission bits are
preserved alongside the regular rwx bits — otherwise a 4755
executable would still be restored as 0755 after editing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous "+" flow in ScriptsSidePanel switched the active tab to
Vault and jumped to the Snippets section, which ripped the user out
of their current terminal context — exactly what the feature was
supposed to avoid.
Replace the cross-panel navigation flow with a lightweight modal
dialog mounted at the App root:
- New component QuickAddSnippetDialog renders over everything and
owns its own form state. Fields: label, command (multi-line), and
package (combobox with allowCreate).
- App.tsx mounts the dialog globally and wires it to updateSnippets /
updateSnippetPackages. No prop drilling through TerminalLayer.
- ScriptsSidePanel still dispatches the same netcatty:snippets:add
window event; the dialog listens for it and opens in place.
- Reverted the navigateToSection / pendingSnippetAdd / openAddTrigger
plumbing in App.tsx, VaultView, and SnippetsManager.
Advanced fields (targets, shortkey, tags) can still be set later
via the full Snippets manager. Cmd/Ctrl+Enter saves from any field.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add "new snippet" button in terminal ScriptsSidePanel (#641)
Previously, adding a new snippet required navigating back to the main
Snippets section from the Vault view. This adds a "+" button in the
search header of the terminal-side ScriptsSidePanel that jumps
directly into the snippet edit flow.
Flow:
- ScriptsSidePanel "+" → dispatches window event `netcatty:snippets:add`
- App.tsx listens → switches activeTab to vault, navigates to Snippets
section, and bumps a monotonic `openSnippetAddTrigger` state
- VaultView forwards the trigger to SnippetsManager
- SnippetsManager watches the trigger and opens its add panel when
the value changes (uses a ref to ignore unrelated remounts)
Closes#641
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: switch add-snippet flow to one-shot pending flag
Codex review pointed out a real bug with the monotonic trigger approach:
when SnippetsManager mounts for the first time with openAddTrigger already
non-zero (the common "+ clicked from terminal while not on Snippets section"
path), the last-seen-trigger ref is initialized to the current value and
the useEffect immediately returns early, so the add panel never opens.
Switch to a cleaner one-shot pending flag:
- App.tsx holds pendingSnippetAdd: boolean + handlePendingSnippetAddHandled
- VaultView forwards pendingSnippetAdd + onPendingSnippetAddHandled
- SnippetsManager opens the add panel on every transition to pendingAdd=true,
then clears the flag via onPendingAddHandled, so subsequent renders and
plain remounts are no-ops
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move useCallback above early return in ScriptsSidePanel
React's rules-of-hooks require all hooks to be called unconditionally.
The new handleAddSnippet useCallback was placed after the
`if (!isVisible) return null;` guard, which tripped eslint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Custom CSS already exists in Settings → Appearance, but major UI
components use only Tailwind utility classes, making it hard for
users to reliably target regions in their custom styles.
This adds stable `data-section="..."` attributes on the root element
of the most commonly customized UI regions so users can write selectors
like `[data-section="snippets-panel"] { font-size: 14px !important; }`
without depending on implementation details.
Instrumented regions:
- snippets-panel (ScriptsSidePanel)
- host-details-panel (HostDetailsPanel via AsidePanel dataSection prop)
- group-details-panel (GroupDetailsPanel)
- serial-host-details-panel (SerialHostDetailsPanel)
- ai-chat-panel (AIChatSidePanel)
- vault-view / vault-sidebar / vault-main / vault-hosts-header / vault-host-list (VaultView)
- terminal-workspace / terminal-workspace-sidebar (TerminalLayer)
- top-tabs (TopTabs — also keeps existing data-top-tabs-root)
Also updated the Custom CSS description and placeholder in both
English and Chinese to list available hooks and show a working
example (snippet panel font-size override).
Closes#642
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the host details / new-host aside panel is open, narrow windows
could clip the panel content because the main area lacked min-w-0 and
the window had no minimum size.
- Add min-w-0 to the main area so flexbox can shrink the host list
portion when the window narrows, keeping the 420px panel fully visible
- Set the BrowserWindow minWidth/minHeight to 1100x640 so the user
cannot drag the window narrower than what the panel + sidebar +
host list need to render comfortably
- Clamp previously saved window dimensions to the new minimum on launch
- Animate the New Host split button and the Terminal / Serial buttons
to collapse with a 200ms transition when the host panel is open,
freeing horizontal space and hiding controls that would be no-ops
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add background terminal jobs for long AI commands
* Bound background job output buffering
* Fix long-running terminal job polling and stop behavior
* Fix terminal job final output and stopping retention
* Wait for PTY stop confirmation before cancelling
* fix: address codex review findings in PTY job refactor
- [P1] Use last occurrence of start marker to skip echoed wrapper command,
preventing control markers from leaking into stdout
- [P1] Add wall-clock timeout for foreground PTY execution so commands that
print continuously still get terminated at the configured limit
- [P2] Add hard deadline for cancellation so jobs that ignore Ctrl+C are
force-finished after 30s instead of staying stuck in "stopping" forever
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-2 codex review findings
- [P1] Use visibleOutput for background job completion to keep offsets
consistent with polling, preventing output loss when raw buffer
(with ANSI codes) truncates earlier than the visible buffer
- [P2] Clarify system prompt that terminal_start requires PTY-backed
sessions, so exec-only SSH sessions are not incorrectly routed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-3 codex review findings
- [P1] Always strip markers from visibleOutput in background job finish
to prevent end-marker lines leaking into terminal_poll results
- [P2] Correct terminal_execute timeout guidance from ~2min to ~60s to
match the actual default commandTimeoutMs (60000)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-4 codex review findings
- [P1] Delay session lock release when cancel is forced (process may
still be running) to prevent sending commands into a busy shell
- [P2] Move scope validation before pendingSessionWriteApprovals so
out-of-scope requests fail fast without blocking the write lock
- [P2] Add session scope checks to handleJobPoll and handleJobStop
so chats that lose access cannot read output or cancel jobs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-5 codex review findings
- [P1] Strip marker lines before they enter the bounded visible buffer
so they never occupy space or leak as partial fragments on truncation
- [P2] Never release session lock after forced cancellation since the
previous process may still be attached to the PTY
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-6 codex review findings
- [P2] Buffer incomplete marker lines across PTY chunks to prevent
partial marker fragments from leaking into visible output
- [P1] Release session lock after 60s delay on forced cancel as
compromise between safety and permanent lock
- [P2] Enforce session scope checks on jobPoll/jobStop for both
dynamic (chatSessionId) and static (NETCATTY_MCP_SESSION_IDS) modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-7 codex review findings
- [P2] validateSessionScope now accepts explicit scopedSessionIds so
static MCP scope mode is enforced for jobPoll/jobStop too
- [P2] Apply per-session execution lock to netcatty:ai:exec IPC path
so it cannot race with active background jobs on the same session
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-8 codex review findings
- [P1] Make wall-clock timeout opt-in via enforceWallTimeout flag,
enabled only for MCP terminal_execute path. Catty Agent's
netcatty:ai:exec keeps the inactivity-based timeout since it has
no terminal_start fallback for long-running streaming commands
- [P2] Always allow handleJobStop regardless of session scope so
the per-session execution lock can always be released after
workspace membership changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-9 codex review findings
- [P1] Enable enforceWallTimeout for netcatty:ai:exec to match the
pre-PR behavior (hard wall-clock deadline). Without this, tail -f
or verbose builds would hold the session lock indefinitely
- [P2] Treat explicit scopedSessionIds=[] as no access rather than
falling through to global scope, matching handleGetContext's
documented behavior
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-10 codex review findings
- [P2] Add bounded startup deadline (30s) for the start marker arrival
even when wall-clock timeout is disabled. Prevents background jobs
from hanging indefinitely on already-chatty PTY sessions
- [P3] Use job-specific marker (not generic __NCMCP_) when stripping
marker lines, so user output containing __NCMCP_ is preserved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-11 codex review findings
- [P2] Skip the 30s startup timeout for foreground execViaPty paths.
It now applies only when maxBufferedChars > 0 (background jobs),
so foreground commands queued behind a busy shell can wait
- [P2] Return empty stdout from getSnapshot() before the start marker
arrives, so an early poll cannot advance nextOffset past pre-start
PTY noise that gets discarded once the real command begins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-12 codex review findings
- [P1] Treat empty chat scopes as no access in validateSessionScope:
if a chat has explicit scoped metadata (even []), enforce strictly
rather than falling through to fallback/global scope
- [P2] Re-add session scope check in handleJobStop for static MCP
clients (scopedSessionIds), while still allowing dynamic chat-scoped
callers to always stop their own jobs even after scope changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-13 codex review findings
- [P2] getScopedJob now requires the caller to present the job's
chatSessionId. Unscoped/static callers cannot reach into another
chat's background jobs even if they learn the jobId
- [P2] Stop button no longer cancels terminal_start background jobs.
They are intentionally long-running, so killing them on every
per-response stop defeats the purpose of the feature. Cleanup on
chat deletion (cleanupScopedMetadata) is preserved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-14 codex review findings
- [P1] terminal_start jobs no longer registered in activePtyExecs so
ACP "Stop" / cancelPtyExecsForSession does not kill them. They are
still managed via terminal_stop and the per-session execution lock
- [P1] Remove enforceWallTimeout from netcatty:ai:exec since Catty
Agent has no terminal_start fallback for long-running commands.
Inactivity timeout still catches genuinely hung processes
- [P2] Forced-cancelled jobs stay in "stopping" (completed=false)
until the 60s lock grace period ends, so callers don't see the
job as completed while the session is still locked
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-15 codex review findings
- [P2] Allow netcatty/jobStop to bypass the chat-cancelled gate so
users can stop terminal_start jobs even after ACP "Stop" was pressed
- [P2] Mark non-zero exit codes as failed (not completed) so callers
don't have to special-case exitCode against status
- [P2] Pre-start cancel: clear startup timer in requestCancel and
detect prompt return on preStartOutput so a queued job that gets
cancelled resolves as "Cancelled", not "startup timed out"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-16 codex review findings
- [P2] Cap preStartOutput for background jobs at maxBufferedChars so
noisy idle PTYs cannot accumulate megabytes before the start marker
arrives or the startup timeout fires
- [P2] On forced cancel, immediately release the session lock and
mark the job as cancelled. The error message clearly states that
the process may still be running, and the caller sees completed=true
exactly when the lock is no longer held — consistent semantics
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-17 codex review findings
- [P2] Disable prompt-suffix completion fallback for background jobs.
Long-running commands often print prompt-like text (nested shells,
ssh, sudo -s, REPLs) and would otherwise be misdetected as completed.
Background jobs rely strictly on the end marker
- [P2] consumeVisibleText now treats \\r as a carriage return that
resets the current line, so progress bars (npm, docker pull, curl)
collapse to the latest frame instead of accumulating every redraw
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-18 codex review findings
- [P2] Pre-start cancel on sessions without a tracked idle prompt now
gets a 2s fallback to finish as Cancelled, instead of waiting the
full forced-cancel window for an end marker that will never arrive
- [P3] Move session-scope validation before the busy-session check so
out-of-scope callers cannot probe the existence/activity of foreign
sessions via busy-state error messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-19 codex review findings
- [P1] Re-enable prompt-suffix completion fallback for background
jobs but with a longer 10s delay so nested shells / REPLs have
time to print past their initial prompt before the recheck
- [P2] Carriage returns now collapse progress redraws across PTY
chunks: \\r is preserved through consumeVisibleText and
applyCarriageReturns erases the trailing line of visibleOutput
when a chunk starts with \\r. Verified with a fake PTY that
emits "10%" then "\\r20%" then "\\r30%\\n" — final output is "30%"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-20 codex review findings
- [P1] Disable prompt-suffix completion fallback for background jobs.
Commands that open child shells with the same prompt as the parent
(bash, zsh, sudo -s, ssh) would otherwise be reported as completed
while the child is still running. Background jobs rely strictly on
the end marker, with their long timeout and explicit terminal_stop
- [P2] Track a monotonic visibleHighWatermark so polling nextOffset
cannot move backwards across CR redraws. serializeBackgroundJob now
returns the latest visible frame when the caller's offset has been
passed by a redraw, instead of returning empty stdout permanently
- [P3] Buffer trailing lines that contain the constant __NCMCP_
prefix (not just the full random marker token) so PTY chunk
boundaries that split the marker mid-token cannot leak _E:0 noise
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-21 codex review findings
- [P2] Foreground execs now also get a hard startup deadline (using
the configured timeoutMs as the limit). Background jobs use a
fixed 30s. Without this, an already-chatty PTY would let onData
re-arm the inactivity timer forever before _S arrives
- [P2] finish() now uses the monotonic visibleHighWatermark for
totalOutputChars on completion, so the final poll's nextOffset
cannot regress relative to earlier polls after CR redraws
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-22 codex review findings
- [P2] cleanupScopedMetadata now also calls clearPendingApprovals so
in-flight approval requests resolve immediately. Otherwise a chat
deleted while an approval was pending would leave the per-session
write lock held until the 5-minute approval timeout expires
- [P2] Allow netcatty/jobStop in observer mode so users can stop
long-running terminal_start jobs that were launched before they
switched to observer mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-23 codex review finding
- [P2] Apply \\r as a "deferred" carriage return: park the cursor at
the start of the line but defer erasure until the next character
arrives. This preserves the latest visible frame for commands like
printf '10%%\\r'; sleep; printf '20%%\\r' that pause between
redraws, while still collapsing continuous progress redraws to a
single frame. Verified: snapshots now show '40%' and '50%' instead
of empty stdout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-24 codex review findings
- [P1] Re-enable prompt fallback for background jobs with a 30s
delay so commands open child shells / REPLs have time to print
past their initial prompt before the recheck. This is the third
time codex has flip-flopped on this — 30s is the compromise
- [P2] Pass chatSessionId to execViaChannel in handleExec so
cancelPtyExecsForSession can interrupt SSH exec-channel commands
scoped to the originating chat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-25 codex review finding
- [P1] Stop in-place CR collapsing in visibleOutput. The collapsed
buffer made polling offsets non-monotonic and could drop finalized
lines after a CR rewrite. Now visibleOutput stores raw bytes (with
\\r dropped at consumeVisibleText to keep the buffer simple), the
256KB cap naturally bounds progress-bar accumulation, and slice
semantics work correctly across all redraw patterns. Consumers
that want a "collapsed view" can post-process
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round-26 codex review findings
- [P2] Carriage returns are now preserved in the raw buffer and
collapsed at serialize time in collapseCarriageReturns. This keeps
monotonic offsets in the buffer while polled output shows the
latest progress frame. A trailing \\r leaves existing content
intact (deferred erasure semantics)
- [P2] netcatty/jobStop now bypasses the confirm-mode approval gate
so a runaway terminal_start job can always be interrupted, even
when the renderer is unavailable
- [P3] requestCancel's one-shot timers (2s pre-start, 150ms reinforce,
30s force-finish) are now tracked and cleared in finish() so they
cannot keep the Node event loop alive after the job has resolved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent crash when codex-acp binary is not found (#645)
When codex-acp is not installed, resolveCodexAcpBinaryPath returned the
bare binary name as a fallback. This caused createACPProvider to spawn a
non-existent process, emitting an async ENOENT error that crashed the app.
Return null instead of the bare name and guard all createACPProvider call
sites so the error is handled gracefully.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: install cross-platform codex-acp binaries in CI build
macOS and Windows CI builds produce both arm64 and x64 packages, but
npm ci only installs optional dependencies for the host platform. This
means the codex-acp native binary for the other architecture is missing
from the packaged app, causing ENOENT crashes for users on the
non-host architecture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add --force to bypass cpu/os constraints for cross-arch install
The platform-specific codex-acp packages declare cpu/os constraints in
their package.json, so npm refuses to install the non-host-arch binary
with EBADPLATFORM. Use --force to bypass this check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.
Closes#632
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fixd:issure #622
* fix: use baseY instead of viewportY for active screen row count
When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use term.clear() for local clear to preserve prompt line
The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve both scrollback and prompt in local clear
term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover
Closes#628
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: support CSV password import and save password in keyboard-interactive auth (#627)
- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback for keyboard-interactive and CSV changes
- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve whitespace in CSV imported passwords
Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: exclude OTP prompts from password detection and guard jump host save
- Add negative patterns (one-time, otp, verification, token, code) to
isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
preventing jump host passwords from overwriting the destination host
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip formula injection guard for password column in CSV export
Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only skip formula guard for data rows, not header row
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.
Closes#625
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.
- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.
Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.
Closes#616
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.
Closes#613
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.
Example custom CSS:
.xterm-container { bottom: 10px !important; }
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: split shortcut in workspace panes and host delete form freeze (#612)
Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.
Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: Shift+right-click context menu and split content loss (#612)
Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.
Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard host-deletion cleanup against unsaved duplicates
The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: check previous host IDs before updating ref in deletion cleanup
Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: open context menu on first Shift+right-click
Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fallback to first live pane when workspace focus is stale
When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: validate focusedSessionId against live workspace panes
focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: persist sidebar appearance overrides for quick-connect hosts
Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:
1. onUpdateHost only updated existing entries (map), never inserted —
change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
undefined for quick-connect hosts — fall back to focusedHost.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts
Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use primary font only in document.fonts.check to fix bold weight fallback
document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.
Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: normalize WebGL fontWeight rendering after terminal connection
Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use global settings for quick-connect host appearance changes
Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.
Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address code review findings
- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
(not just fontWeight) so all appearance changes on ephemeral hosts
update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When selecting "Custom..." from the shell dropdown, opens a modal with:
- Full-width input field for shell executable path
- Path validation feedback (valid/not found/is directory)
- Quick-pick buttons for common shell paths
- Confirm/Cancel buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use the same styled Select component as other Settings dropdowns for
visual consistency. Removes the unstyled native <select> element.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)
Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use setIgnoreMenuShortcuts instead of preventDefault for Alt+Arrow
preventDefault in before-input-event blocks the keydown from reaching
xterm.js. Instead, use setIgnoreMenuShortcuts to disable Chromium's
built-in navigation shortcut while letting the key event pass through
to the terminal renderer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Font weight change/reset now patches the raw (un-merged) host record
instead of writing back the merged host with group defaults baked in
- Bold font fallback uses effectiveFontWeight (per-host) instead of
global terminalSettings.fontWeight in both update paths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Font weight now updates on running terminals when slider is adjusted
(uses per-host effectiveFontWeight instead of global terminalSettings)
- Scrollbar theme colors preserved when switching terminal themes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add fontWeight/fontWeightOverride to Host and GroupConfig interfaces
- Add resolve/has/clear helpers in terminalAppearance.ts
- Wire per-host font weight through TerminalLayer → ThemeSidePanel
- ThemeSidePanel shows "Use Global" button when host overrides weight
- createXTermRuntime resolves per-host font weight
- Add to INHERITABLE_KEYS for group config inheritance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add range slider (100-900) in the Font tab of ThemeSidePanel
- Wire through TerminalLayer → App.tsx → useSettingsState
- Changes persist immediately via updateTerminalSetting('fontWeight')
- Display current weight value in status bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add scrollbar slider theme colors derived from foreground color
(scrollbarSliderBackground/Hover/Active — new in xterm 6.0)
- Update log messages to say 'DOM' instead of 'canvas'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove macOS traffic light dots and title bars from shell SVG icons.
Replace with clean, simple, iconic designs using rounded squares,
bold typography, and distinctive colors for each shell.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add per-host option for Backspace sends ^H (#602)
Add backspaceSendsCtrlH option at host and group level to send ^H (0x08)
instead of DEL (0x7F) when pressing Backspace, for legacy system compatibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add per-host backspace behavior option (#602)
Add backspaceBehavior option at host and group level. When not configured,
xterm default behavior is preserved with zero interception. When set to
'ctrl-h', remaps DEL (0x7F) → ^H (0x08) for legacy system compatibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use remapped backspace byte for broadcast input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: support custom keywords and colors in global keyword highlighting (#590)
Add ability to create custom keyword highlight rules in global settings
(Settings > Terminal > Keyword Highlighting):
- Per-rule enable/disable toggle for both built-in and custom rules
- Add custom rules with label, regex pattern, and color picker
- Delete custom rules (built-in rules cannot be deleted)
- Pattern validation with error feedback
- Custom rules sync across devices via cloud sync
- i18n support (en, zh-CN)
Built-in categories (Error, Warning, OK, Info, Debug, URL/IP/MAC) are
preserved and cannot be deleted, only toggled and recolored.
Closes#590
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: use dialog modal for adding custom keyword highlight rules
Replace inline form with a proper modal dialog:
- Button opens dialog instead of showing inline inputs
- Dialog has label+color, regex pattern, and live preview
- Reset and Add buttons side by side in footer area
- Add common.add i18n key (en, zh-CN)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ui: unify button styles in keyword highlight section
Both buttons now use ghost variant with equal flex-1 width for a
cleaner, balanced layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ui: fix keyword highlight rule list alignment
- Add placeholder spacer (w-5) for built-in rules to match delete
button width on custom rules, keeping color pickers aligned
- Move regex pattern to second line for custom rules
- Use block+truncate for label and pattern text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ui: hide regex, show edit/delete icons after label for custom rules
- Remove regex pattern display from rule list
- Add pencil (edit) and trash (delete) icons after custom rule label,
visible on hover
- Edit opens the same dialog pre-filled with rule data
- Dialog supports both add and edit modes with appropriate titles/buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ui: remove toggle dots, simplify edit/delete to plain icons
- Remove the red enable/disable dot button from all rules
- Replace Button wrappers with plain Lucide icons for edit/delete
(no hover background, just cursor pointer)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve multi-pattern rules on edit, keep disabled state on reset
- Editing a custom rule now preserves patterns beyond the first one
- Reset to default colors no longer force-enables disabled rules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace all patterns on edit instead of preserving hidden ones
When editing a custom rule, save only the single user-visible pattern
rather than silently keeping extra patterns the user cannot see.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve regex whitespace and multi-pattern rules on edit
- Stop trimming regex patterns on save (only trim for empty check)
- If pattern field unchanged during edit, preserve all original
patterns so changing just label/color doesn't drop extra regexes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve additional patterns when editing custom rule
When editing, replace only the first pattern (the one shown in the
dialog) and keep any additional patterns intact to prevent data loss
for multi-pattern rules from sync or import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Immersive mode was already hardcoded to true with a no-op setter.
Clean up all dead code:
- Remove isImmersive param from useImmersiveMode hook
- Remove immersiveMode/setImmersiveMode from useSettingsState
- Remove toggle from SettingsPage and SettingsAppearanceTab
- Remove sync read/write of immersiveMode setting
- Remove i18n keys for the removed toggle
- Simplify App.tsx conditionals
Kept: useImmersiveMode hook (core logic), CSS classes (fade overlay),
sync type field (backward compat), storage key.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When viewing Vault/SFTP, clear terminal theme vars from tab bar so it
uses the UI theme colors. Terminal theme is only applied when the
terminal layer is visible, or during theme sidebar preview.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
activeTopTabsThemeId was only set when the theme sidebar was open,
causing the tab accent line to lose its terminal-derived color when
the sidebar was closed. Now it always tracks the focused terminal's
theme, with sidebar preview taking priority when open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tab top accent line was using hsl(var(--primary)) which is only set
when the sidebar theme preview is active. Changed to use
var(--top-tabs-accent, hsl(var(--accent))) matching all other tab
elements, so the color is correct both with and without sidebar open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(i18n): add translations for group config panel
* feat(models): add GroupConfig data model, resolution logic, and encryption
Add the GroupConfig interface for group-level default settings that hosts
inherit. Includes ancestor-chain resolution (A/B/C merges from A, A/B,
A/B/C), host-level application logic, storage key, and secure field
encryption/decryption for sensitive GroupConfig fields.
Part of #220.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(state): add groupConfigs state management with encryption
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(ui): create GroupDetailsPanel with full config editing
Side panel for editing group-level default configuration using AsidePanel.
Includes General, SSH, Telnet, Advanced, Mosh, and Appearance sections
with sub-panel navigation for Proxy, Chain, EnvVars, and Theme selection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(vault): wire GroupDetailsPanel, replace rename dialog with full config panel
Replace all group rename dialog triggers with the new GroupDetailsPanel sidebar.
The hover edit button, context menu, and tree view edit callbacks now open the
full group configuration panel instead of a simple rename dialog.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(connect): apply group config defaults at connection time
When connecting to a host, merge group-level default configuration so
hosts inherit their group's settings for auth, protocol, appearance,
and other inheritable fields. Connection logs still reference the
original host's label/hostname.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(sync): include groupConfigs in sync and export payloads
Add groupConfigs to SyncPayload, SyncableVaultData, buildSyncPayload,
and applySyncPayload so group connection defaults are preserved during
cloud sync and data import/export. Also wire groupConfigs into the
vault object in SettingsPage so it flows through to the sync payload
builder.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(vault): update group configs on move and delete
* feat(host-panel): show inherited group defaults as placeholders
When editing a host that belongs to a group with configuration, group
default values now appear as placeholder text in username, startup
command, and charset fields where the host doesn't have its own value.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clean up unused imports in GroupDetailsPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(group-panel): add/remove protocol sections, editable parent group
- SSH and Telnet sections are now add/remove — click "Add Protocol"
to enable, "..." menu to remove. Only enabled protocols override hosts.
- Parent Group is now editable via Combobox dropdown for quick
group moving.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move SSH-specific fields into SSH protocol section
Startup Command, Legacy Algorithms, Proxy, Host Chaining,
Environment Variables, and Mosh are all SSH-specific and now only
visible when SSH protocol is added. Only Charset remains as a
shared field in the Advanced section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: hide charset and appearance when no protocol is added
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: close Add Protocol dropdown after selection
Use controlled open state to explicitly close the dropdown when a
protocol is selected, preventing residual content from overlapping
the newly rendered section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: apply group defaults in TerminalLayer sessionHostsMap
Terminal component was re-reading the original host from the hosts
array by hostId, bypassing the group defaults applied in
handleConnectToHost. Now sessionHostsMap applies resolveGroupDefaults
+ applyGroupDefaults when building the host object for each session,
so Terminal sees the merged credentials/settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move Add Protocol to bottom, fix i18n for protocol/font labels
- Add Protocol button moved below Appearance section
- Added i18n keys: addProtocol, removeProtocol, fontFamily, fontSize
- All hardcoded English strings replaced with t() calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace font family text input with TerminalFontSelect dropdown
Use the same font selector component as settings, showing available
terminal fonts with preview. Includes "Use Global" reset button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(group-panel): match HostDetailsPanel key/certificate selection pattern
Replace the simple Combobox key selector with the same credential selection
flow used in HostDetailsPanel: a popover with Key/Certificate options,
inline combobox per type, and proper badge display with certificate icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(group-panel): add Local Key File option to credential selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(group-panel): add identityFilePaths to GroupConfig and Local Key File option
- Added identityFilePaths to GroupConfig interface and INHERITABLE_KEYS
- GroupDetailsPanel now supports Key, Certificate, and Local Key File
credential selection, matching HostDetailsPanel's full credential flow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent local key file input from overflowing panel width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: constrain local key file input width with w-0 flex-1
Native input elements have a large default min-width. Using w-0 with
flex-1 forces the input to shrink within the flex container.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add overflow-hidden to SSH Card to contain local key file input
Matches HostDetailsPanel's Card which uses overflow-hidden on the
credentials section to prevent long file paths from overflowing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add min-w-0 to key file path row for proper text truncation
Flex children need min-w-0 for truncate to work correctly,
otherwise the text pushes the container wider.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: force key file path text truncation with inline max-width calc
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use fixed 320px max-width on key file path text to force truncation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add overflow-hidden to AsidePanelContent to prevent content overflow
The root cause was the inner div of AsidePanelContent only had
overflow-x-hidden which was being overridden by ScrollArea's viewport.
Changed to full overflow-hidden with w-full box-border.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: override Radix ScrollArea viewport's display:table in AsidePanel
Radix ScrollArea Viewport wraps content in a div with
display:table and min-width:100%, causing content to expand beyond
the panel width. Override this on AsidePanelContent's ScrollArea
to use display:block and min-width:0 instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: critical issues — seed new hosts from group defaults, validate group names, fix empty import
- HostDetailsPanel: When groupDefaults has values for port/username/charset,
new hosts start with undefined/empty so group defaults take effect via
applyGroupDefaults() instead of being blocked by hardcoded values
- GroupDetailsPanel: Validate group name in handleSubmit to reject '/' and
'\' characters, matching the old rename dialog behavior, with visual error
- useVaultState: Check groupConfigs !== undefined instead of truthy so that
importing an empty array [] properly clears all group configs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: safe prefix replacement, remove dead code, extract shared resolveEffectiveHost
- Replace all .replace(oldPath, newPath) / .replace(sourcePath, newPath) with
explicit prefix slicing (newPath + str.slice(oldPath.length)) in handleSaveGroupConfig
and moveGroup for more robust path renaming
- Remove dead c.path === oldPath branch in finalConfigs mapping since updatedConfigs
already contains the config with newPath
- Extract resolveEffectiveHost helper in App.tsx to deduplicate group defaults
resolution in _handleTrayPanelConnect and handleConnectToHost
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve undefined port on save when group has port default
form.port || 22 was forcing port to 22 even when intentionally left
undefined for group inheritance. Now uses nullish coalescing and only
defaults to 22 when no group port default exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: SSH-adjacent field detection, chain host defaults, telnet inheritance, theme clear
- hasSshFields() now checks proxyConfig, hostChain, startupCommand,
legacyAlgorithms, environmentVariables, moshEnabled, moshServerPath,
and identityFilePaths so the SSH section auto-opens when editing
- Chain hosts in sessionChainHostsMap now get group defaults applied
via resolveGroupDefaults + applyGroupDefaults
- Added telnetEnabled to GroupConfig interface and INHERITABLE_KEYS;
save handler sets telnetEnabled: true when Telnet section is on
- Theme/font "Use global" clear now sets override to false instead of
undefined, preventing parent group theme from leaking through
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: review round 4 — sync, SFTP, port forwarding, type safety, UX
- Scan groupConfigs in encrypted credential guard (P1 security)
- Add groupConfigs to auto-sync payload and three-way merge (P1 sync)
- Apply group defaults in SFTP connections (P1 SFTP)
- Apply group defaults in all port forwarding paths (P1 port forwarding)
- Make Host.port optional to fix unsafe type cast (P1 type safety)
- Fix port input empty → 0 instead of undefined (P2)
- Add port placeholder showing inherited value (P2)
- Mutual exclusion of group/host detail panels (P2)
- Fix sub-panel width jump 420px → 380px (P2)
- Validate duplicate group path on rename/reparent (P2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: review round 5 — null guard, empty array inheritance, memo comparator, form reset
- Guard groupConfigs import against null payload (P1 crash)
- Validate duplicate path on moveGroup drag-drop (P2 data corruption)
- Clear empty environmentVariables to undefined for group inheritance (P1)
- Clear empty hostChain to undefined for group inheritance (P2)
- Add groupConfigs to SftpView memo comparator (P1 stale defaults)
- Add key={editingGroupPath} to GroupDetailsPanel for form reset (P1)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: review round 6 — copy credentials, protocol dialog use effective host
- Apply group defaults in handleCopyCredentials (P2)
- Apply group defaults in hasMultipleProtocols check (P2)
- Pass effective host to ProtocolSelectDialog (P2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: serialize protocol:'ssh' marker to persist SSH section in group config
- Add protocol:'ssh' as marker field in handleSubmit SSH block
- Detect protocol:'ssh' in hasSshFields() to preserve section on reopen
- Clean up protocol field in removeSsh()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve interactive shell cwd for relative path autocomplete (#594)
When `listSessionDir` receives a relative path (e.g. "."), the exec
channel defaults to the home directory instead of the interactive
shell's cwd. Prepend a cwd-resolution preamble that finds the sibling
shell process via $PPID and reads its /proc/<pid>/cwd, then cd's into
it before running `find`. Gracefully degrades to the old behavior if
resolution fails.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prefer prompt-based cwd over stale fallback for path autocomplete
Two bugs caused `cd ` autocomplete to show home dir instead of current dir:
1. resolveAutocompleteCwd skipped prompt cwd extraction when currentWord
was empty (the "cd " trailing space case), always returning the stale
fallbackCwd set at connection time.
2. chooseAutocompleteCwd discarded prompt cwd starting with "~/" in favor
of fallbackCwd, even though the prompt cwd is more current when OSC 7
is not supported by the remote shell.
Now: always attempt prompt extraction for empty/relative words, and prefer
prompt cwd ("~/path") over potentially stale fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removing the !h.pinned filter from recentHosts — if user only
connects to pinned hosts, the Recent section would never appear.
Showing a host in both Pinned and Recent is acceptable since they
convey different information (favorite vs just used). Also removes
debug console.log statements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The useMemo-derived sessionById could be stale in the callback
closure, preventing lastConnectedAt from being set on connect.
Use a ref to always read the latest session map.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(models): add pinned and lastConnectedAt fields to Host
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(i18n): add translations for pinned and recently connected sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(vault): add pin toggle, lastConnectedAt tracking, and computed sections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(vault): render Pinned and Recently Connected sections at root level
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(vault): add pin/unpin context menus and hover edit buttons in all views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(vault): make breadcrumb a drop target for moving groups back to root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(settings): add toggle for showing recently connected hosts section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve lint warnings for unused vars and unnecessary dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: improve pin performance and add pop-in animation
- Use ref for hosts in callbacks to avoid stale closures and
unnecessary re-renders when hosts array changes
- Add pop-in spring animation on pinned host cards with staggered
delay for a satisfying visual effect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fix pop-in animation visibility and improve pin responsiveness
- Move @keyframes pop-in out of @layer base to global scope so inline
styles can reference it
- Add translateY to animation for a bouncier, more satisfying feel
- Use pinnedAnimKey to force card remount on pin changes so animation
replays each time
- Wrap onUpdateHosts in startTransition for non-blocking pin updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only animate newly pinned card, increase section spacing
- Track lastPinnedId instead of global animKey so only the newly pinned
card gets the pop-in animation, not all existing pinned cards
- Clear animation state via onAnimationEnd for clean re-trigger
- Add mb-4 to Pinned and Recent sections for better visual separation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(vault): show pin indicator icon on pinned host cards
Small semi-transparent pin icon in top-right corner of pinned host
cards in the Hosts section (grid view only).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: use solid amber/yellow pin indicator icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: tilt pin indicator icon 45 degrees
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: replace pin indicator with filled amber star on all pinned cards
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move lastConnectedAt tracking to App-level handleConnectToHost
Previously updating lastConnectedAt in VaultView's handleHostConnect
which could be lost during tab switches. Now tracked at the App level
where all connections are handled, ensuring the timestamp persists
regardless of UI navigation state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Codex review findings (P2 issues)
1. useStoredBoolean now syncs across same-window components via
CustomEvent dispatch, so Settings toggle immediately updates VaultView
2. lastConnectedAt updated after connectToHost succeeds, not before
3. Pinned and Recently Connected sections now respect active search
and tag filters
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address second round Codex review findings
1. Track lastConnectedAt on actual 'connected' status instead of
session creation - handles via handleSessionStatusChange wrapper
2. Covers tray panel connections since all paths go through
updateSessionStatus
3. Pinned/Recent cards now honor multi-select mode with checkbox
UI instead of triggering connections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address third round Codex review findings
1. [P1] Use hostsRef in handleSessionStatusChange to avoid
overwriting concurrent host changes with stale snapshot
2. [P2] Exclude pinned/recent hosts from main host list at root
level to prevent duplicate cards on screen
3. [P2] Remove Pin action from tree view context menu since tree
view has no pinned ordering/indicator support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address fourth round Codex review findings
1. [P1] Remove leftover onToggleHostPinned references in HostTreeView
root-level component that were missed in previous cleanup
2. [P2] Add draggable + onDragStart to pinned/recent host cards so
drag-and-drop between groups still works
3. [P3] Fix grouped view header count to exclude hosts already shown
in pinned/recent sections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use functional state update for lastConnectedAt, dedupe pinned from recent
1. [P2] Add updateHostLastConnected using setHosts(prev => ...) functional
update pattern (same as updateHostDistro) to avoid overwriting concurrent
host changes when multiple sessions connect simultaneously
2. [P3] Exclude pinned hosts from Recently Connected section to prevent
duplicate cards between the two top sections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: wire showRecentHosts into settings sync, clear pin on duplicate
1. [P2] Add showRecentHosts to SyncPayload settings so the preference
survives cloud sync and settings export/import
2. [P2] Clear pinned and lastConnectedAt on duplicated hosts so copies
don't inherit pin/recent status from the original
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The mainWindow variable was never cleared when the window was destroyed,
unlike settingsWindow which had a proper 'closed' handler. This caused
getMainWindow() to return a destroyed window object, preventing the
activate handler from correctly detecting the main window was gone and
creating a new one.
Fixes#587
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On macOS, when the main window is closed but the settings window is
still open, clicking the Dock icon would focus the settings window
instead of re-creating the main window.
- focusMainWindow() now explicitly finds the main window via
getWindowManager() instead of using getAllWindows()[0]
- activate handler creates a new main window even when other
windows (settings) are still open
Fixes#587
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Set the settings window title to "netcatty Settings" and prevent
the HTML <title> tag from overriding it, so macOS Dock menu and
Window menu can distinguish between the two windows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex review: remove references to setImmersiveModeState
in rehydration, IPC sync, and cross-window storage handlers that
would throw after the state setter was removed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Immersive mode is now always on — the UI chrome automatically adapts
to match the active terminal theme. The toggle in Appearance settings
has been removed and the TerminalLayer preview logic simplified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Re-fit terminal and restore viewport scroll position after search bar
toggle to prevent content jumping. Preserves bottom-stick behavior
and removes toolbar bottom border for cleaner appearance.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keep top tabs theme vars applied based on focused terminal theme,
not just during sidebar preview. Prevents the color flash when
switching themes or closing the theme sidebar panel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add popular terminal themes sourced from official repos and
iTerm2-Color-Schemes:
- GitHub Dark / GitHub Light (primer/github-vscode-theme)
- Ubuntu (classic Ubuntu terminal)
- One Dark Pro (Binaryify/OneDark-Pro)
- Horizon (jolaleye/horizon-theme-vscode)
- Palenight (whizkydee/vscode-palenight-theme)
- Panda (tinkertrain/panda-syntax-vscode)
- Snazzy (sindresorhus/hyper-snazzy)
- Synthwave '84 (robb0wen/synthwave-vscode)
- Vesper (minimal dark theme)
- Kanso Dark / Kanso Light (zen-inspired)
Total built-in themes: 62 → 74
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Active tab top line uses accent/primary color instead of foreground
- Remove terminal toolbar bottom border to reduce visual clutter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unify theme item style across ThemeSelectPanel (host details) and
ThemeSelectModal (settings) with a shared ThemeList component featuring
compact swatch previews, dark/light/custom grouping, and no-rounded
selection highlight.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SFTP Filename Encoding: inline layout with label and select on same row
- Linux Distribution: extract from Appearance into its own Card with Tux icon
- Chain panel: remove non-functional Add Host button, add search filter for
available hosts, fix long hostname overflow with truncation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a session disconnects due to a transport error (e.g. "Keepalive timeout",
"ECONNRESET"), the error message is now surfaced in the disconnect dialog
instead of showing a generic "Disconnected" label.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When keepaliveInterval was set to 0 (the default, documented as "disabled"),
the code treated 0 as falsy and fell back to 10000ms. This caused ssh2 to
send keepalive@openssh.com global requests every 10s. Devices with non-OpenSSH
SSH implementations (e.g. NOKIA/ALCATEL) that don't reply to these requests
would have their connections terminated after ~40s (4 × 10s keepalive timeout).
Closes#581
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After all file data is written to the buffer, the progress bar shows
100% but the remote rz is still processing. Now a "finalizing" flag
is sent with the last progress event, and the UI displays "Waiting
for remote..." instead of the misleading 100% uploading state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10s was too short for large files (466MB+). After sending all data,
the remote rz still needs time to read from TCP buffer and write to
disk before it can reply with ZRINIT/ZFIN. 120s accommodates slow
links and large files while still catching genuinely dead sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
withTimeout was resolving silently after 10s, which made a stalled
xfer.end()/zsession.close() look like a successful transfer. Now it
rejects with "ZMODEM handshake timeout", so the .catch handler fires
and shows an error toast instead of a false success.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- terminalBridge: cancel zmodemSentry in telnet error/close, serial
error/close, and cleanupAllSessions before deleting sessions
- sshBridge: cancel zmodemSentry in all 4 SSH cleanup paths (stream
close, conn error, conn timeout, conn close)
- zmodemHelper: wrap xfer.end() and zsession.close() with 10s timeout
to prevent indefinite hang when cancel/abort leaves internal
zmodem.js Promises unresolved (prevents fd leak)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Unhandled header: ZACK" was triggered by a SOCKS5 proxy on the
server causing abnormal protocol behavior, not a real lrzsz issue.
The handler's condition was too broad (any active send) and could
mask genuine protocol errors. Keep ZRINIT and ZRPOS handlers which
have narrow conditions and address real scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zmodem.js only handles ZACK in specific Send session states (after
ZSINIT, during file negotiation). Some receivers send extra ZACKs as
generic acknowledgements that arrive outside these states, causing
"Unhandled header: ZACK". Since ZACK is just an ack, ignoring it
is safe and keeps the transfer going.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
setTimeout(50) per chunk would cap upload speed at ~1.28MB/s because
ssh2's 32KB highWaterMark triggers backpressure on almost every 64KB
write. setImmediate yields to the I/O phase without a fixed delay,
letting TCP flush as fast as possible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Large file uploads (466MB+) could saturate the SSH/PTY write buffer
with all data sent synchronously, causing the ZEOF/ZFIN handshake
at the end to be delayed — the UI shows 100% but the transfer hangs
while TCP flushes the backlog.
- All writeToRemote callbacks now return stream.write() result
- Sentry sender tracks _needsDrain flag when write returns false
- Upload loop calls waitForDrain() which yields 50ms when backpressure
is detected, letting TCP flush buffered writes between chunks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In both discover and resolve-cli handlers, treat --version failure
(exception or empty output) as an invalid CLI. This catches .app
bundles, broken symlinks, and other non-executable paths that pass
the filesystem check but aren't actually usable CLI tools.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
normalizeCliPathForPlatform used existsSync which returns true for
directories like /Applications/Codex.app. Added statSync.isFile()
check on non-Windows platforms so .app bundles are not mistaken for
CLI executables.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add ZMODEM (lrzsz) file transfer support for terminal sessions
Adds ZMODEM protocol detection and file transfer capability to all
terminal session types (Local, SSH, Telnet, Mosh, Serial). Uses
zmodem.js library with main-process sentry pattern to intercept
binary data before string decoding, avoiding IPC pipeline changes.
- zmodemHelper.cjs: shared ZMODEM sentry with Electron dialog integration
- terminalBridge.cjs: encoding:null for PTY + sentry wrappers for all session types
- sshBridge.cjs: sentry wrapper for SSH stream data
- preload.cjs + global.d.ts: ZMODEM event IPC bridge and TypeScript types
- useZmodemTransfer.ts: React hook for ZMODEM transfer state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve charset decoding and add ZMODEM progress UI
- zmodemHelper: pass raw Buffer to onData, let callers handle decoding
- terminalBridge: use StringDecoder for telnet/serial, UTF-8 for local/mosh
- sshBridge: restore iconv decoder for SSH session charset support
- ZmodemProgressIndicator: floating progress bar with cancel button
- Terminal.tsx: wire useZmodemTransfer hook + toast notifications
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ZMODEM listener cleanup, stream leak, and toast dedup
- preload: clean up zmodemListeners on session exit (memory leak)
- zmodemHelper: add ws.on('error') handler to close write stream on failure
- Terminal: use ref guard to prevent duplicate toast notifications
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address code review findings for ZMODEM
- cancel/consume error now send IPC event to renderer (prevents stuck UI)
- sanitize download filename with path.basename (path traversal prevention)
- add on_detect concurrency guard (deny if transfer already active)
- formatBytes: handle negative, zero, and TB+ values safely
- closeSession: cancel active ZMODEM before destroying transport
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent double-notification on cancel and stream error resilience
- Guard .then()/.catch() in promise chain: skip if cancel() already handled
- Download: add writeAborted flag to stop on_input after stream error
- Upload: pre-compute file stats to avoid O(N²) statSync calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use zsession.abort() instead of close() on dialog cancel
close() is only available on Send sessions. Calling it on a Receive
session throws, leaving the sentry's internal _zsession dangling and
causing subsequent terminal data to be consumed by the abandoned
ZMODEM session (terminal freeze). abort() is defined on the base
ZmodemSession class and properly fires session_end to reset the sentry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle ZFIN/OO mismatch as successful transfer
When sz exits over SSH, the shell prompt often arrives before the
ZMODEM "OO" end marker, causing zmodem.js to throw a protocol error.
Since ZFIN was already exchanged (= all file data transferred), treat
this specific error as a successful completion and forward the shell
prompt data back to the terminal via sentry re-consume.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — UTF-8 decoder, ZFIN abort, session exit cleanup
- terminalBridge: use StringDecoder for local/mosh PTY to handle
multi-byte UTF-8 split across buffer boundaries (prevents garbled
CJK/emoji output)
- zmodemHelper: on ZFIN/OO success path, use _on_session_end() instead
of abort() to avoid sending CAN (Ctrl-X) bytes to the remote shell
- useZmodemTransfer: listen to onSessionExit to reset state when the
session dies mid-transfer (prevents stuck progress indicator)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — file collision handling and stream flush
- Download: auto-rename with (1), (2), etc. if file already exists
in the target directory, preventing silent overwrite
- Download: wait for all write streams to finish flushing before
resolving the session_end promise, ensuring data is on disk when
the UI reports completion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — Windows PTY string compat and Telnet binary safety
- Local/Mosh PTY: handle string data from Windows node-pty which
ignores encoding: null; convert to Buffer before sentry.consume()
- Telnet: bypass IAC negotiation during active ZMODEM transfer to
preserve 0xFF bytes in binary data
- Telnet writeToRemote: escape 0xFF as 0xFF 0xFF per Telnet spec
so ZMODEM binary data is not treated as IAC commands
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — Windows PTY guard, Telnet IAC, stream cleanup
- Local/Mosh: skip ZMODEM sentry on Windows where node-pty can't
provide raw bytes; fall back to original string pipeline
- Telnet: always run IAC negotiation (even during ZMODEM) since the
Telnet layer still escapes 0xFF as IAC IAC; the existing handler
already correctly collapses IAC IAC → single 0xFF
- Download: destroy un-ended write streams on session_end to prevent
hanging promises and leaked file descriptors on abort
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — early session start, progress throttle, no dup start
- Download: call zsession.start() before showing folder picker dialog
so lrzsz doesn't time out waiting for ZRINIT
- Download: throttle progress IPC to ~10 updates/sec (100ms interval)
to avoid overwhelming renderer on fast links
- Download: remove duplicate zsession.start() at bottom of Promise
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle ZRPOS and prevent terminal flood after ZMODEM abort
- Add 500ms cooldown after ZMODEM abort: suppress residual protocol
bytes from remote rz/sz that would otherwise flood the terminal
- Send 8x CAN (Ctrl-X) on abort/cancel/error to force remote end to
stop transmitting even if the initial abort sequence was lost
- Handles "Unhandled header: ZRPOS" gracefully (zmodem.js doesn't
support error recovery, so abort is the correct response)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: send Ctrl+C after abort in all cancel/error paths
Debian's rz stays attached to the TTY after receiving CAN sequences.
The cancel() path already sent Ctrl+C via scheduleRemoteInterruptAfterCancel,
but dialog-cancel and consume-error paths did not. Now all three abort
paths (dialog cancel, consume error, explicit cancel) send Ctrl+C after
150ms to ensure the remote rz/sz process exits and the shell regains control.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add interruptRemote for SSH ZMODEM sentry
Pass SSH stream.signal("INT") as interruptRemote callback so the
ZMODEM helper can send SIGINT to the remote process when cancelling
transfers, complementing the Ctrl+C byte sent via writeToRemote.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: dialog-cancel abort uses module-level helper to avoid ReferenceError
sendExtraAbortBytes and writeToRemote are closure-scoped inside
createZmodemSentry, not accessible from handleUpload/handleDownload.
Extract abortRemoteProcess as a module-level function that takes
writeToRemote as a parameter, used in both dialog-cancel paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: dialog cancel throws instead of returning to avoid false complete
When user dismisses the file/folder picker, handleUpload/handleDownload
now throw "Transfer cancelled" instead of returning normally. This
ensures the .catch() handler fires (sending error event) rather than
.then() (which would incorrectly send complete event).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — preserve transferType in progress events
- useZmodemTransfer: copy transferType from progress events so the
transfer direction is preserved if renderer re-subscribes after
the initial detect event was missed
- zmodemHelper: clean up upload loop comments (backpressure handled
via 64KB chunks + setImmediate yield per iteration)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: codex review — guard stale session cleanup, delete partial downloads
- Promise chain .then/.catch/.finally now compare currentZSession
identity (=== zsession) instead of truthiness, preventing a new
transfer from being clobbered by the old promise settling
- Aborted/incomplete downloads are deleted from disk on session_end
so users don't end up with corrupt partial files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: unconditional cooldown suppression after ZMODEM abort
The previous cooldown checked if data "looks like residual ZMODEM"
which fails for sz's file content (arbitrary printable bytes). Now
cooldown unconditionally drops ALL incoming data for 2 seconds after
abort, with repeated CAN bursts to ensure the remote sz stops. This
prevents the terminal flood seen when cancelling large sz downloads
on fast connections.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a stream error appends a new assistant message, the previous
one is no longer lastAssistantMessage. Its pending approval tool
calls were rendered as interrupted, losing approve/reject buttons.
Now they retain approval status and controls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tool calls were rendered both in the assistant message (as pending)
and in separate tool-result messages (as completed), causing
duplicates. Additionally, new pending tool calls appeared above
completed ones due to message ordering.
Fix: render completed tool calls only from tool-result messages,
and render pending tool calls after all results so they appear
at the bottom in chronological order. Unresolved tool calls from
earlier assistant messages or cancelled sessions are shown inline
as interrupted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On Windows the resolved path may be a .cmd shim which spawn()
cannot execute without shell: true. Keep acpCommand as the bare
"copilot" from AGENT_DEFAULTS and let the system resolve it via
PATH at launch time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move COPILOT_HOME temp dir cleanup before the acpProviders entry
check so it runs even if provider creation failed before the entry
was stored in the map.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- When building managed copilot agent config, set acpCommand to the
resolved path instead of bare "copilot" so custom paths work for
ACP launches
- Add USERPROFILE fallback in prepareCopilotHome for Windows where
HOME may not be set
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
copilotConfigInfo was declared with let inside the try block but
referenced in the finally block for temp dir cleanup. Block scoping
caused a ReferenceError that broke list-models for Copilot agents.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add COPILOT_HOME cleanup in list-models finally block to prevent
temp directory accumulation on each model fetch
- Remove verbose console.log in mcpServerBridge dispatch/connect/auth
that fired on every MCP call for all agents
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix matchesManagedAgentConfig acpCommand matching for copilot by
using a lookup table instead of hardcoded ternary
- Remove dead nodeRuntimePath variable and unused 4th arg to
buildMcpServerConfig
- Fix model loading useEffect double-triggering by reading
agentModelMap via ref instead of dependency
- Add temp COPILOT_HOME cleanup in cleanupAcpProvider
- Remove dead acpForceProviderReset Set (never populated after
stop/resume refactor)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adapt copilot agent additions to the refactored managed agent
architecture (resolveAgentPath + buildManagedAgentState pattern).
Add copilot to ManagedAgentKey type and MANAGED_AGENT_META.
Keep main's resolveMcpServerRuntimeCommand (process.execPath +
ELECTRON_RUN_AS_NODE) over PR's runtimeCommand parameter approach.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a previously stored custom path no longer exists (e.g. CLI
reinstalled to a different location), aiResolveCli now falls back
to PATH-based detection instead of returning unavailable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move agent dedup/consolidation from a useEffect (that depended on
externalAgents while also setting it) into resolveAgentPath, using
setExternalAgents(prev => ...) callback form. Use a ref for
defaultAgentId to avoid dependency cycles and keep it in sync
across concurrent codex+claude resolves.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use size="sm" (rounded-md) instead of className override that kept
the rounded-xl from the default md size, which appeared circular.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reduce item padding, gaps, icon sizes, and font sizes for a denser list
- Use rounded square (rounded-lg) avatars instead of circles, remove border
- Add tooltip on host label and connection string for long text overflow
- Shrink section headers and group items to match compact style
- Remove border from selected host items for cleaner look
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add dialogActionScopeId to distinguish SftpView and SftpSidePanel
dialog actions, preventing cross-instance interference
- Refine selectionScope to clear tree selections per-pane instead of
using clearAllExcept, avoiding side effects on other SFTP surfaces
- Remove selection clearing from tab switch/move/add handlers; clearing
now only happens on focus side change and file interaction
- Reset keyboard selection and lastSelectedIndex when selections are
externally cleared
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user switches focus between left and right panes, clear all
pane selections. Combined with the per-interaction clearing in
toggleSelection/rangeSelect, this ensures:
- Selecting files clears other panes' selections
- Switching sides clears all selections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move selection clearing from tab switch and pane focus handlers into
toggleSelection/rangeSelect. This means:
- Switching tabs just to look around preserves all selections
- Actually clicking/selecting files clears other tabs' selections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clearing same-side inactive tab selections on tab switch is intentional
UX — stale selections on hidden tabs would be confusing when switching
back. Reverts the "preserve same-side" change from 05c48b3.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add i18n keys for "Host" and "Path" labels in delete confirmation
dialog (was hardcoded English, broken under zh-CN)
- Pass moved tab ID as extra keepId when clearing tree selections after
moveTabToOtherSide, since the ref still has pre-move state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
clearSelectionsExcept was clearing all tabs including same-side inactive
ones, causing users to lose file selections when switching between tabs
on the same side. Now only the opposite side's selections are cleared.
Also scoped tree selection clearing to only affect opposite-side pane
IDs, preventing mounted but hidden SFTP surfaces from losing state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The paste check only compared sourceSide vs focusedSide, treating all
tabs on the same side as "same pane". Now it also compares connectionId
so copying from one tab and pasting to a different tab on the same side
works correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Revert the stale action clearing in inactive panes (e9ad65f). When
multiple tabs exist on the same side, the inactive tab's effect could
fire before the active tab's, clearing the action and causing it to
be handled by the wrong pane or not at all.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tree view's own onKeyDown handler had the same issue as the global
keyboard shortcuts: pressing ArrowDown with no selection would skip the
first item. Apply the same fix (reset focus to -1 for empty selection).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents SFTP shortcuts (Delete, Enter, etc.) from firing while
unrelated dialogs are open, which could cause unintended file
operations from outside the SFTP panel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When no files are selected, Shift+Arrow would use anchor=-1 causing
invalid slice ranges. Now anchor is set to 0 when Shift is held, so
range selection starts from the first item correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When selections are cleared (e.g. by switching panes), pressing
ArrowDown would skip the first item because the keyboard focus
defaulted to index 0 and then moved to 1. Now an empty selection
resets focus to -1 so the first arrow press selects item 0.
Applies to both list and tree views.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a dialog action's targetSide matched but the pane was inactive,
the action was left in the store. If the pane later became active, it
would fire the stale action unexpectedly. Now inactive panes clear the
action to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The showSelectionHighlight check in SftpFileRow's areEqual was causing
all rows to re-render when switching focus between panes. Now only rows
that are actually selected re-render on highlight changes, avoiding
unnecessary work for large file lists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add clearSelectionsExcept to clear all file/tree selections except the
target pane, called on focus change, tab switch, tab add, and tab move
- Fix SftpFileRow areEqual to include showSelectionHighlight so highlight
updates when focus changes between panes
- Improve delete confirmation dialog with host/path context and separate
single vs multi-delete descriptions
- Fix hover style on selected rows to prevent flicker
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Display the connection's host label at the top of new folder, new file,
rename, overwrite, and delete confirmation dialogs so users can see
which machine the operation targets.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SftpSidePanel doesn't sync with the global activeTabStore, so
useActiveTabId would return the main SftpView's tab id, causing
side panel panes to be treated as inactive. Add forceActive prop
to bypass the activeTabId check for contexts that manage pane
visibility themselves.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When multiple SFTP connections were open as tabs on the same side,
keyboard-triggered actions (delete, rename, new folder, new file) were
executed on every mounted tab instead of just the active one. This was
because all hidden SftpPaneView instances shared the same dialog action
handler and React batched their effects before clear() could prevent
duplicates.
- Add isActive parameter to useSftpDialogActionHandler so only the
active tab responds to keyboard shortcut actions
- Compute real isActive state in SftpPaneView using useActiveTabId
instead of hardcoding true
- Clear opposite side's file selection on pane focus change to prevent
cross-pane selection leaking into actions
Closes#569
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the generic terminal SVG icon with the actual Netcatty brand
logo (blue rounded-rect with terminal + cat tail motif).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace yellow pulsing dot with a spinning Loader2 icon when cloud
provider is in connecting state. Also show "Connecting..." text
instead of "Not connected" during the connection attempt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The top indicator line on active tabs (sessions, logview, vaults, SFTP)
was hardcoded to foreground color (white), making it always white
regardless of the system accent color setting. Changed all 4 tab
indicator lines to use --top-tabs-accent / --accent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The encoding guard was rejecting "auto" which is the default encoding
for nearly all connections, making same-host optimization never trigger.
Frontend now allows "auto" through. Backend resolves "auto" to the
actual session encoding via resolveEncodingForRequest and only proceeds
with exec cp when the resolved encoding is UTF-8.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: optimize same-host SFTP transfer with remote cp command
When both panels are connected to the same remote host, use SSH exec
`cp -a` instead of downloading to local temp then re-uploading. This
eliminates 2x bandwidth usage and reduces latency for same-host transfers.
Closes#561
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: optimize same-host directory transfer with single cp -ra command
For same-host directory transfers, use a single `cp -ra` command via SSH
exec instead of recursively walking the directory and copying files one
by one. This makes directory copies nearly instant on the remote server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use endpoint cache key for same-host detection and guard non-UTF-8 paths
Address two code review issues:
1. Compare per-connection cache keys (hostname+port+protocol+sudo+username)
instead of just hostId for same-host detection. This prevents false
positives when the same hostId has different session-time overrides.
2. Restrict exec-based cp paths to UTF-8 compatible encodings only.
Non-UTF-8 encodings (e.g. gb18030) need encodePathForSession which
shell exec cannot use — fall back to download+upload for those cases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: directory cp semantics, cancellation, and auto encoding guard
1. Use `cp -ra source/. target/` instead of `cp -ra source target` to
copy directory contents into target, preserving merge semantics when
the target directory already exists (avoids extra nesting level).
2. Check cancellation state before and after sameHostCopyDirectory call
so cancelled transfers don't finalize as completed.
3. Exclude 'auto' from exec-safe encodings since auto can resolve to
non-UTF-8 (e.g. gb18030) at the session level.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: wire cancellation into same-host copy paths
1. Single file cp -a: check transfer.cancelled before and after
execSshCommand so cancelled transfers don't proceed as success.
2. Directory cp -ra: accept transferId, register in activeTransfers
so cancelTransfer can flag it, and check cancelled state at each
async boundary. Cleanup via finally block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: abort remote cp process on transfer cancellation
Add execSshCommandCancellable() that wires the SSH exec stream into
transfer.abort, so cancelTransfer can close the stream and kill the
remote cp process immediately instead of waiting for it to finish.
Used in both single-file (cp -a) and directory (cp -ra) same-host paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: close exec stream immediately if cancelled before callback fires
Check transfer.cancelled at the start of the exec callback and close
the stream right away, preventing the remote cp from running when
cancellation happened between the exec() call and callback delivery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fallback to download+upload when remote cp is unavailable
On non-POSIX remotes (e.g. Windows SSH servers) where cp is absent,
same-host optimization now gracefully falls back to the existing
download+upload transfer path instead of failing the transfer.
- Single file: try cp -a first, fall back to temp file on non-zero exit
- Directory: sameHostCopyDirectory returns { success: false } instead of
throwing, frontend falls back to recursive transferDirectory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: cache cp unavailability to avoid repeated exec failures
Track sftpIds where remote cp failed in cpUnavailableSet so subsequent
file transfers in the same session skip the exec attempt and go directly
to download+upload, avoiding per-file exec round-trip overhead on
non-POSIX remotes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip transferFile for directories already handled by same-host copy
Add !task.isDirectory guard to the else branch so successful
sameHostCopyDirectory doesn't also trigger a redundant transferFile
call that would duplicate data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: dereference symlinks in same-host copy to match SFTP behavior
Use cp -aL instead of cp -a so symlinks are dereferenced (copied as
file contents), matching the existing SFTP download+upload flow which
always transfers resolved file data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* revert: remove -L flag from same-host cp to avoid recursing symlinked dirs
Revert cp -aL back to cp -a. The -L flag dereferences all symlinks
including symlinked directories, which can unexpectedly recurse into
large unrelated directory trees. Using cp -a preserves symlinks as-is,
which is safer and consistent with how the transfer UI treats symlink
directories as non-recursive entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: refine cp unavailability caching and remove dead import
1. Only cache sftpId in cpUnavailableSet on exit code 127 (command not
found). Other failures (permission denied, disk full) are transient
or path-specific and should not disable cp for the entire session.
2. Check cpUnavailableSet at the top of sameHostCopyDirectory to skip
exec attempt on known non-POSIX remotes. Also cache 127 exits from
directory copies.
3. Remove unused execSshCommand import from transferBridge (replaced by
local execSshCommandCancellable) and revert its export from sftpBridge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent key file path from overflowing host details panel
Add min-w-0 to flex containers and flex items displaying key file
paths. Without this, flex items default to min-width: auto which
prevents truncate from working and causes long file paths (e.g.
from the file picker) to blow out the panel width.
Closes#551
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add overflow-hidden to AsidePanel to prevent content overflow
The root cause of key file paths overflowing the panel was the
AsidePanel container itself lacking overflow-hidden. Even though
inner elements had min-w-0 and truncate, the absolute-positioned
panel div allowed content to visually escape its bounds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add overflow-hidden to credentials Card and key path row
Ensure truncation works by adding overflow-hidden at multiple
levels: the Port & Credentials Card container and each key file
path flex row.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use w-0 flex-1 to force key file path truncation
min-w-0 alone is insufficient in nested flex layouts. Setting w-0
with flex-1 forces the element to start at zero width and only grow
to fill available space, guaranteeing truncation works.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: unify directory download with upload transfer system
Directory downloads previously used a completely separate implementation
with custom queue management, progress tracking, and concurrency control
(~390 lines in useSftpViewFileOps.ts). This caused the download UI to
show only a single aggregate task without child file details, unlike
uploads which showed parent + child tasks.
Replace the custom download implementation with a new downloadToLocal()
method in useSftpTransfers that reuses the existing transferDirectory/
transferFile infrastructure. Downloads now:
- Show parent task with child file tasks (same as uploads)
- Use the configurable transfer concurrency setting
- Support cancellation through the same mechanism
- Share progress tracking and conflict detection code
Net reduction of ~260 lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove dead code from directory download refactor
Remove listSftp, mkdirLocal, and RemoteFile imports that were only
used by the old custom directory download implementation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle symlink directories in transfers and remove dead code
- Use isNavigableDirectory() instead of type === "directory" in
transferDirectory so symlinks pointing to directories are
recursed into correctly (fixes both upload and download paths)
- Remove unused deleteLocalFile prop from useSftpViewFileOps
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use connection ID for download tasks and cancel child streams
- Use pane connection ID (not SFTP session ID) as sourceConnectionId
so download tasks are properly associated with the host and visible
in filtered transfer views
- Cancel all active child transfer streams at the backend when parent
is cancelled, not just the parent ID — stops data transfer immediately
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add symlink cycle detection and propagate child failures
- Add visitedPaths Set to transferDirectory to detect and skip
symlink directory cycles that would cause infinite recursion
- Check for failed child tasks after transferDirectory completes
and mark parent as failed instead of falsely reporting success
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use depth limit for symlink loops and handle EEXIST on mkdir
- Replace visited-paths cycle detection with a depth limit (64),
which reliably catches symlink loops that generate new path strings
each hop (e.g. /dir/link/link/link...)
- Handle EEXIST errors in mkdirLocal gracefully so re-downloading
to an existing directory doesn't abort the entire transfer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: throw on depth limit exceeded and mark downloads non-retryable
- Depth limit now throws instead of silently returning, so exceeding
it surfaces as a failed transfer rather than an incomplete success
- Set retryable: false on downloadToLocal tasks since retryTransfer
cannot resolve the synthetic "local" connection ID
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: track symlink depth only and verify EEXIST target is directory
- Change depth guard to only count symlink directory hops, not total
directory depth, so legitimate deep trees are not rejected
- After catching EEXIST on mkdirLocal, stat the path to verify it is
actually a directory — throw if a regular file exists at that path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove dead props from callbacks and surface download failures
- Remove mkdirLocal and deleteLocalFile from useSftpViewPaneCallbacks
interface and passthrough (fixes TS2353 build error)
- Show error toast when downloadToLocal returns "failed" status,
not just when it throws
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: track child transfer IDs outside React state for reliable cancel
Child transfer IDs were only discoverable via transfersRef.current,
which lags behind setTransfers due to React batching. This caused
two race conditions:
1. Cancellation: child streams started between setTransfers and render
were not cancelled at the backend, continuing to write data.
2. Failure detection: hasFailedChildren checked transfersRef which
might not reflect recently-failed children, marking partial
downloads as successful.
Fix: track active child IDs in activeChildIdsRef (a mutable Map
outside React state) for immediate visibility during cancellation.
Check child failure status inside setTransfers functional updater
where the latest state is guaranteed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve actual progress on partial failure and count symlink dirs
- Don't force transferredBytes to totalBytes when some children failed,
so the progress bar accurately reflects the partial completion
- Use isNavigableDirectory in countDirectoryFiles and estimateDirectoryBytes
so symlink directories are included in size/count estimates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: symlink count, progress on fast downloads, and child cancellation
1. countDirectoryFiles: use isNavigableDirectory so symlink dirs are
recursed into, keeping totals consistent with transferDirectory
2. Final status: compute actual completedCount from children instead
of relying on totalBytes which may be 0 if the background scan
hasn't finished yet
3. Catch block: detect cancellation from error message (not just
cancelledTasksRef) so child-initiated cancels don't show as errors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add symlink depth guard to countDirectoryFiles and estimateDirectoryBytes
Both helper functions now track symlink depth and stop recursing
when MAX_SYMLINK_DEPTH is exceeded, consistent with transferDirectory.
Prevents infinite recursion on symlink directory cycles during the
background file count/size scan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reliable final status and non-retryable child tasks
1. transferDirectory now returns the count of failed child transfers,
tracked outside React state. downloadToLocal uses this count
directly instead of reading from setTransfers updater (which may
be deferred by React batching), ensuring the correct status is
returned to the caller for toast messages.
2. Child tasks explicitly inherit retryable from the parent task.
For downloadToLocal (retryable: false), this prevents showing
retry actions on failed children whose "local" targetConnectionId
cannot be resolved by retryTransfer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add ancestor path cycle detection for symlink directories
The depth-only guard allowed up to 32 pointless traversals before
stopping a symlink cycle (e.g. dir/link -> .). Add an ancestorPaths
Set that tracks the current recursion stack — if a directory's source
path is already in the set, it's an immediate cycle and is skipped
with zero wasted traversals. The depth limit remains as a hard backstop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: don't recurse into symlink directories during transfers
Revert to only recursing into real directories (type === "directory")
in transferDirectory, countDirectoryFiles, and estimateDirectoryBytes.
Symlink directories are now transferred as regular entries instead of
being followed, eliminating all symlink cycle risks without needing
complex cycle detection that can't reliably work with unresolved
remote paths.
Also clean up activeChildIdsRef in processTransfer (both success and
error paths) to prevent memory leaks from pane-to-pane directory
transfers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: filter "." entries and recurse into symlink dirs with depth guard
1. Filter both "." and ".." in all recursive functions — some SFTP
servers include "." in readdir, causing infinite self-recursion.
2. Restore symlink directory recursion in transferDirectory with a
symlinkDepth counter (max 32). Symlink dirs that exceed the limit
are excluded from the dirs list (treated as files). This is needed
because startStreamTransfer cannot transfer a directory as a file,
so skipping symlink dirs caused child transfer failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add symlink depth guard to count/estimate helpers
countDirectoryFiles and estimateDirectoryBytes now track symlinkDepth
consistently with transferDirectory, preventing infinite recursion on
symlink cycles in the background file count/size estimation.
Also fixes:
- Remove fragile string-based cancellation detection in downloadToLocal
- Clean up cancelledTasksRef in downloadToLocal catch block
- Move MAX_SYMLINK_DEPTH before its first use
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use path reconstruction instead of string replace for duplicate conflicts
resolveConflict's "duplicate" action used String.replace to swap the
filename in the target path, but this replaces the first occurrence
which can corrupt the path if the filename also appears in a parent
directory name. Use joinPath(getParentPath(...), newName) instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip over-depth symlink directories instead of treating as files
When symlinkDepth exceeds MAX_SYMLINK_DEPTH, symlink directories
were falling through to regularFiles and being passed to transferFile,
which cannot transfer directories and would produce confusing errors.
Now they are skipped entirely with a warning log.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: count skipped symlinks as errors and process subdirs concurrently
1. Symlink directories skipped at MAX_SYMLINK_DEPTH now increment
totalErrors so the parent task is marked failed instead of
silently reporting success with incomplete content.
2. Sibling subdirectories are now processed with Promise.all instead
of sequential await, restoring cross-directory concurrency that
the old download implementation had. Files within each directory
still use the configurable worker pool concurrency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sequential subdirs to prevent SFTP overload and check dir errors in processTransfer
1. Revert subdirectory processing to sequential (for...of await) to
prevent unbounded concurrent SFTP requests from nested Promise.all
+ worker pools across the directory tree. File-level concurrency
within each directory is still governed by getTransferConcurrency().
2. processTransfer now captures transferDirectory's error count return
value and marks the parent task as "failed" when child transfers
fail, instead of unconditionally marking "completed".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove redundant completed state update for directory transfers
Directory success path no longer writes "completed" in both the
directory-specific block and the generic block. The directory-specific
block now only handles the failure case with early return; success
falls through to the generic completed block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: route partial directory failures through shared completion path
The early return for directory transfer failures skipped cache
invalidation, target pane refresh, and onTransferComplete callbacks
(needed by cut/paste to clear clipboard). Now partial failures flow
through the same cleanup path as successes — cache is cleared,
target is refreshed, and completionHandler is called with the
correct "failed" status.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restrict symlink directory recursion to downloadToLocal only
Add followSymlinks parameter (default false) to transferDirectory,
countDirectoryFiles, and estimateDirectoryBytes. Only downloadToLocal
passes true — uploads and pane-to-pane copies retain their original
behavior of treating symlink directories as regular entries.
This prevents existing upload/copy flows from expanding symlinked
directory trees (which could duplicate content or trigger cycles),
while still allowing local downloads to recursively copy through
symlink directories with depth protection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: disable retry for partial dir failures and fix symlink file count
1. Mark partially failed directory transfers as retryable: false to
prevent retry from replaying the entire directory without conflict
checks, which would silently overwrite already-copied files.
2. In countDirectoryFiles and estimateDirectoryBytes, skip over-depth
symlink directories entirely instead of counting them as files.
This makes the totals consistent with transferDirectory which also
skips these entries, preventing impossible progress like "10/11".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: make SFTP folder transfer concurrency configurable
The number of files transferred in parallel during folder uploads/
downloads was hardcoded to 4. Add a setting (1-16, default 4) in
Settings > SFTP so users can tune it for their server and network.
The value is read from localStorage at transfer start time, so
changes take effect on the next folder transfer without restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sync transfer concurrency setting across windows
Add notifySettingsChanged broadcast, IPC onSettingsChanged handler,
and storage event listener for the transfer concurrency setting so
changes propagate to all open windows immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move setSftpTransferConcurrency after notifySettingsChanged
The useCallback referenced notifySettingsChanged before it was
defined (const is not hoisted), causing a ReferenceError on mount.
Move the definition after notifySettingsChanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the transfer child list crosses the virtualization threshold (80
items), viewportHeight may be 0 if the layout hasn't been measured yet.
Previously this caused all children to render on the first frame,
creating a lag spike when clicking "show details" on large transfers.
Use MAX_PANEL_HEIGHT (480px) as a fallback viewport, capping the
initial render to ~25 rows (17 visible + 8 overscan) instead of
potentially thousands.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add workspace focus indicator style setting (dim vs border)
Users can now choose between two focus indicator styles for split
terminal panes:
- Dim: reduces opacity of unfocused panes (current default)
- Border: shows a colored border on the focused pane (old style)
The setting is in Settings > Terminal > Workspace Focus Indicator.
Implementation uses a CSS data attribute on documentElement to
toggle between the two styles, avoiding prop threading.
Closes#556
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sync workspace focus style across windows
Add cross-window notification handling for the workspace focus style
setting so changes in the Settings window take effect in the main
terminal window immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove popd from FOLDER_ONLY_COMMANDS since it does not accept
path arguments (it pops from the directory stack)
- Change recent-history score from 700 to 720 to avoid collision
with spec option suggestions (also 700), giving recent history
a clear rank: path (750) > recent history (720) > options (700)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The list view branch of sftpNavigateTo was missing the
_kbSelectionState.delete() call that the tree view branch and
other navigation handlers already had.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keyboard shortcuts:
- BASIC_NAV_KEYS fallback now only applies when hotkeyScheme is
disabled, so user keybinding customizations are respected
- Clear _kbSelectionState on directory navigation (sftpOpen,
sftpGoParent, sftpNavigateTo) to prevent stale anchor/focus
- Guard sftpOpen tree-view fallback to only fire in tree view mode
- Use treeActionSelection (filters "..") in sftpNavigateTo
Autocomplete PATH_COMMANDS:
- Remove subcommand-first tools (docker, kubectl, go, cargo, java,
make, npx) that don't take paths as first arguments
- Add pushd (was in FOLDER_ONLY but missing from PATH_COMMANDS)
- Add tee, du, df, chroot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When typing arguments for file-related commands (cat, vim, cd, etc.),
files in the current directory should appear before history entries.
Lower the recent-history score from 900 to 700 so path suggestions
(score 750) rank higher. This makes "cat com<Tab>" show compose.yaml
before historical commands like "cat /other/path".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoid creating a new object on every keydown event by moving the
constant lookup table outside the callback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When basicNavAction was set, matched was intentionally null but the
existing `if (!matched) return` check exited before reaching the
action handler. This made Enter and Backspace non-functional in all
hotkey modes, not just disabled mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enter (open) and Backspace (go parent) are essential navigation keys
that must work even when the user has disabled custom SFTP hotkeys.
Add a basic navigation fallback that fires before the disabled check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add sftpNavigateTo keybinding (Ctrl+Enter / ⌘+Enter) to navigate
into a selected directory. Works in both tree view and list view.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the hardcoded Enter (open file/directory) and Backspace (go to
parent) handlers into the keybinding system so users can customize
them in Settings. Arrow key navigation remains hardcoded as it has
complex anchor/focus state tracking unsuitable for simple action mapping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the keyboard selection state was re-synced (e.g. after a mouse
click changed the selection), the anchor variable still held the old
value from before re-sync. This caused Shift+Arrow to select from
position 0 instead of from the clicked item. Destructure anchor and
focus together so both are updated when re-sync occurs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shift+Arrow selection was broken because the anchor position was
re-derived from the selected files Set on each keypress, causing
it to jump unpredictably. Track anchor and focus indices separately
per pane so Shift+Arrow correctly extends the range from the
original starting position.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pressing Backspace in the SFTP file list now navigates to the parent
directory, similar to file managers like Windows Explorer and Finder.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Increase HostDetailsPanel width from 380px to 420px to give more
room for inner content blocks
- Add max-w-full to AsidePanel/AsidePanelStack root so the panel
never exceeds its parent container width
- Add min-w-0 to ScrollArea and inner content div in AsidePanelContent
to allow flex children to shrink properly
- Use overflow-x-hidden instead of overflow-hidden to preserve
vertical layout flexibility
Closes#551
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unnecessary eslint-disable directive in useAutoSync.ts
- Use localStorageAdapter.remove() instead of bare localStorage in
useSftpFileAssociations.ts (no-restricted-globals)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add default file opener setting for SFTP
Add a global default opener that is used as fallback when no
per-extension file association exists, eliminating the need to
select an editor for every new file type.
The default opener is stored as a special "*" key in the existing
file associations map, so it syncs and persists automatically.
Settings UI provides three options: always ask (current behavior),
built-in editor, or a chosen system application.
Closes#550
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use reserved key for default opener to avoid extension collision
Replace "*" with "__default__" as the default opener storage key to
prevent a theoretical collision with files named "foo.*" where
getFileExtension would return "*".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip built-in editor default for known binary files
When the global default opener is set to built-in editor, binary files
(zip, png, etc.) should not be opened as text. Fall back to the chooser
dialog for known binary formats instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: store default opener in separate localStorage key
Move the default opener out of the FileAssociationsMap into its own
storage key (STORAGE_KEY_SFTP_DEFAULT_OPENER) to completely eliminate
any possibility of key collision with file extensions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: sync global SFTP bookmarks via cloud sync
Global SFTP path bookmarks were stored only in localStorage and not
included in the cloud sync payload, so they could not be synced across
devices. Add them to the sync settings, with auto-sync detection via
a custom event and in-memory snapshot rehydration on import.
Local bookmarks remain device-specific by design.
Closes#548
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: deduplicate global SFTP bookmarks by path during merge
When the same path is bookmarked independently on two devices, each
generates a different random ID. The entity-array merge preserves both,
creating duplicates. Add path-based deduplication after settings merge,
following the same pattern used for known hosts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sync global bookmarks across renderer windows via storage event
When cloud sync imports bookmarks in the Settings window, the main
window's in-memory snapshot stays stale. Listen for cross-window
storage events on the bookmark key to auto-rehydrate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Symlinks pointing to directories (DirLinks) were sorted with regular
files instead of being grouped with directories. Reuse the existing
isNavigableDirectory() helper so these entries sort alongside real
directories.
Closes#549
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(sftp): add onListDirectory to SftpPaneCallbacks interface
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(sftp): implement onListDirectory in left and right callbacks
* feat(sftp): add tree view i18n keys
* feat(sftp): add list/tree view mode toggle to toolbar
* feat(sftp): add viewMode state and tree view conditional rendering to SftpPaneView
* feat(sftp): implement SftpPaneTreeView with lazy loading and context menu
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix(sftp): resolve lint errors in tree view implementation
Rename inner `t` and `ts` variables in onListDirectory callbacks to
`toSize`/`toTs`/`ms` to avoid shadowing the outer `t` translation param.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix(sftp): resolve post-merge lint errors
- Remove duplicate sftp.context.copyPath i18n key (upstream added it too)
- Remove unused AlertCircle import from SftpPaneFileList (upstream removed usage)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* perf(sftp): optimize SftpPaneTreeView render pipeline
Split useMemo into two stages so selection changes no longer
rebuild the full node descriptor array. Extract stable
selection-aware callbacks (drag, copy, delete) via refs so
TreeNode React.memo can reliably bail out. Remove unused props
(onNavigateTo, draggedFiles), move NodeDescriptor type to
module scope, and fix selectedFiles undefined bug in context menu.
* feat(sftp): add path-aware rename and delete for tree view
Wire renameFileAtPath and deleteFilesAtPath through the full
callback stack so tree view context menu actions operate on
full paths instead of basenames. Update useSftpPaneDialogs to
accept entryPath in openRenameDialog and resolve parent dir
in handleDelete, keeping list view behaviour unchanged.
* fix: harden SFTP tree view actions and selection
* fix: support tree selection shortcuts and nested create targets
* fix: keep SFTP tree view sorting in sync
* Improve SFTP tree view interactions and refresh behavior
* Optimize SFTP tree refresh and pane state usage
* Reduce remaining SFTP tree performance overhead
* Fix nested SFTP drop target routing
* Restore keyboard access to parent tree entry
* Revert "Display approved AI commands in terminal sessions before their output. (#546)"
This reverts commit 6d19413025.
* Fix SFTP tree view review issues: accessibility, view persistence, and polish
- Add aria-pressed/aria-checked to view mode toggle buttons for accessibility
- Preserve tree expanded state across view mode switches (CSS hidden instead of unmount)
- Add cross-window localStorage sync for view mode preferences
- Add loading/reconnecting overlay UI for tree view
- Fix toggleExpand concurrent load guard and file list memo dependencies
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix review round 2: scroll jank, memo correctness, path handling, a11y
Critical:
- Fix rAF scroll throttle capturing stale scrollTop (use ref for latest value)
- Add sftpDefaultViewMode to memo comparator to react to settings changes
- Replace ad-hoc path splitting in handleDelete with getParentPath/getFileName
- Add fullPath to permissionsState prop type in SftpOverlays
Important:
- Remove treeSelectionState from handleNodeClick/handleTreeContainerKeyDown
deps to prevent full tree re-render on every expand/collapse
- Add role="radiogroup" container and aria-label to view toggle buttons
- Wrap JSON.parse in try/catch for storage event handler
- Deduplicate getParentPath call in renameFileAtPath
- Parallelize reloadExpandedPaths with Promise.all
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Clean up review round 3: dead code, logging, and minor optimizations
- Remove dead isParentNavigation field from tree selection store (always
false since ".." entries are filtered before entering the store)
- Replace empty catch blocks in dialog handlers with logger.warn
- Extract duplicated initialViewMode expression in SftpPaneView
- Stabilize handleSetViewMode by using refs for callbacks instead of
depending on the entire callbacks object
- Remove redundant FINISH_LOADING dispatch on error path in
loadChildrenForPath (LOAD_ERROR already removes from loadingPaths)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add same-pane drag-move, move-to dialog, and fix breadcrumb/tree sync
Features:
- Same-pane drag-and-drop to move files between directories in tree view
- "Move to..." context menu with path input dialog and autocomplete
- "Move to parent directory" quick action in context menu
- "Navigate to" context menu item for directories
- Error state UI with retry button in tree view
- Breadcrumb path deferred display during loading
Fixes:
- Fix breadcrumb and tree content showing different paths during navigation
by atomically syncing resolvedRootPath and rootEntries in a single effect
- Fix toolbar displayPath updating before files load (defer until !loading)
- Reconnection detection and session error reporting in tree directory listing
UI improvements:
- Column widths use minmax()+fr instead of percentages with min-width protection
- Column headers truncate with overflow protection
- buildSftpColumnTemplate utility shared between tree and list views
- Column resize limits per field
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Reapply "Display approved AI commands in terminal sessions before their output. (#546)"
This reverts commit f739e81e8d7691eb33965f6c431623a257fd8b4b.
* fix: resolve remote-to-local drag transfer source pane
* fix: invalidate target cache after transfers
* fix: reload tree root after create mutations
* fix: use receive callback for tree drop targets
* fix: trigger pane refresh after transfer completion
* fix: handle transfer refresh tokens only once
* fix: show move-to-parent for direct children
* fix: refresh list view after move-to-parent changes
* fix: address review issues in transfer refresh and retry flows
* feat: improve list view keyboard and folder drops
* fix: strengthen list view keyboard selection feedback
* style: make list view selection more obvious
* fix: keep list selection visible during keyboard navigation
* fix: rerender list rows when selection changes
* fix: sync list selection highlight updates
* style: align list selection with tree view
* style: hide list selection highlight when pane is unfocused
* feat: clear list selection when clicking empty space
* refine transfer row layout and clear list selection on empty click
* perf: make transfer size discovery asynchronous
* perf: parallelize SFTP transfers and show per-file progress for directories
- Parallelize file transfers within directories (4 concurrent workers)
- Batch pre-create all directories before file uploads begin
- Run conflict check and size discovery concurrently
- Parallelize external drag-drop file uploads (4 concurrent workers)
- Show individual child file progress under parent directory task
- Parent directory task displays file count progress (e.g. "3/10 files")
- Child tasks auto-cleanup on parent completion or cancellation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refine sftp transfer panel ux
* fix sftp sidebar and upload task flow
* polish sftp transfer interactions
---------
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
HTML spec forbids <button> inside <button>. Change the outer session
list item from <button> to <div role="button"> to fix the hydration
warning while preserving click and keyboard accessibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset cloud sync connect button when OAuth popup is closed
When users close the OAuth popup without completing authorization,
the connect button was stuck in "Connecting" state indefinitely
(up to 5-minute timeout).
Changes:
- Track OAuth popup window and poll for closure (Google, OneDrive)
- Cancel OAuth callback server when popup is closed, immediately
rejecting the pending promise instead of waiting for timeout
- Reset provider status via disconnectProvider on auth failure so
the connect button returns to clickable state
- Suppress toast for user-initiated cancellation (popup closed)
- Also reset GitHub provider status on device flow failure
Closes#542
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use resetProviderStatus instead of disconnectProvider on auth failure
disconnectProvider tears down existing connections (signOut, delete
adapter, clear merge base). If a user was re-authenticating and
cancelled, this would destroy their working connection.
Add resetProviderStatus() that only resets the UI status to
'disconnected' without any teardown side effects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add resetProviderStatus to CloudSyncHook interface
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove noreferrer from OAuth popup to enable window tracking
noreferrer implies noopener in browser spec, causing window.open()
to return null and defeating the popup closure detection entirely.
Safe to remove since OAuth targets are trusted providers (Google,
Microsoft) and the Referer is just a localhost URL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard resetProviderStatus and cancel delayed popup on early failure
- resetProviderStatus only resets if status is 'connecting', preserving
already-authenticated providers when sync initialization fails
- Cancel the delayed setTimeout for window.open if callbackPromise
rejects before 100ms, preventing a stray popup and leaking interval
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset GitHub provider status when device flow modal is closed
The modal onClose only hid the modal and stopped the polling flag,
but the provider status stayed at 'connecting'. Now calls
resetProviderStatus('github') so the button returns to clickable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.
Closes#543 (Nerd Fonts portion)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.
Closes#543 (Nerd Fonts portion)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve AI chat history across reconnects
* fix: retarget restored AI sessions on reconnect
* feat: format tool call results with proper line breaks
Extract stdout/stderr from structured results and unescape \n/\t
so command output displays with real line breaks like terminal output.
Supports both JSON object {stdout,stderr} and executor text formats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restrict unescape to stdout/stderr fields only
Plain strings may contain legitimate backslash sequences (file paths,
regex patterns) that should not be converted. Only apply unescape to
stdout/stderr fields extracted from command execution results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review findings for AI chat reconnect
1. Add explicit activeTerminalTargetIds guard in shouldRetargetActiveSession
to prevent retargeting sessions owned by other terminals, making the
invariant locally verifiable.
2. Only preserve orphaned terminal sessions with hostIds — workspace,
local, and serial sessions generate fresh IDs and would be permanently
unreachable, wasting MAX_STORED_SESSIONS quota.
3. Clear stale streaming state when restoring a session whose ACP handle
was already cleaned up (e.g., reconnect during mid-response), so the
user can send new messages.
4. Restore overflow-hidden on user message bubbles to prevent content
bleeding past rounded border corners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round 2 review findings
1. Fix streaming state clear: only clear for sessions whose targetId
doesn't match current scope (restored from different terminal),
not for built-in Catty chats that never set externalSessionId.
2. Exclude local/serial sessions from preservation: their synthetic
hostIds (local-*/serial-*) change on every open and can never be
matched back.
3. Preserve non-zero exitCode in formatted tool results so failed
commands show a visible failure signal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only clear streaming state during retarget, not for all restored sessions
The previous condition (targetId !== scopeTargetId) also fired for
built-in Catty sessions during normal operation, killing active streams.
Now streaming is only cleared when shouldRetargetActiveSession is true,
meaning the session came from a disconnected terminal where any
in-flight response is guaranteed to be dead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address round 3 review findings
1. Clear externalSessionId during retarget to prevent stale ACP handle
from surviving if retarget runs before orphan cleanup.
2. Only retarget in visible AI panels — hidden/background panels should
not race to claim orphaned sessions.
3. Remove unescapeTerminalOutput — data flow trace confirms real newline
characters arrive at the component. The unescape was corrupting
legitimate backslash sequences in paths and patterns.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only ACP-cleanup deleted sessions, not preserved ones
Preserved sessions may be reused on reconnect. Running aiAcpCleanup
on them asynchronously could race with a newly started ACP conversation
on the same session ID, tearing down the fresh provider.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: abort in-flight streams during retarget and restore ACP cleanup
1. Abort the active request's AbortController when retargeting a session
with stale streaming state. Prevents late chunks from the old run
appending into the restored chat.
2. Restore ACP cleanup for all orphaned sessions (not just deleted ones).
Preserved sessions get a new externalSessionId on next use, so
cleaning the old one prevents subprocess leaks without affecting
future conversations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard hidden panels from session ownership and skip null map entries
1. Only assign restored sessions in visible panels — hidden panels
should not race to own sessions via setActiveSessionId, preventing
MCP/tool calls from being bound to the wrong terminal.
2. Skip null entries in activeSessionIdMap when building
activeTerminalTargetIds — deleted chats should not block same-host
history matching on other terminals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard MCP sync behind visibility and cancel exec/approvals on retarget
1. Only sync MCP session metadata from visible panels to prevent
hidden panels from overwriting the scope mapping.
2. Cancel pending approvals and in-flight exec (Catty + ACP) during
retarget, matching handleStop behavior. Prevents stale tool results
and approval prompts from reappearing after session retarget.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore MCP sync for hidden panels
MCP scope is keyed by chatSessionId so hidden panels don't overwrite
visible panels' mappings. The isVisible guard was breaking background
chats that need updated terminal session metadata after reconnects
or workspace changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove unused deletedIds variable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add deviceType field to Host model for network device support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: pass deviceType through session metadata pipeline
Thread deviceType from Host model through AITerminalSessionInfo, IPC
types, and mcpServerBridge so AI agents can inspect device type per session.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: route network device SSH sessions to raw PTY execution
When deviceType === 'network', handleExec now uses execViaRawPty
instead of execViaPty so vendor CLIs (Huawei VRP, Cisco IOS, etc.)
receive commands as-is without POSIX shell wrapping or markers.
The command blocklist is also skipped for network devices, consistent
with the existing serial session bypass. AI context description updated
to document the raw-execution behaviour for network device sessions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add network device mode toggle to host settings UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add network device awareness to Catty Agent system prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: extend network device mode to Catty Agent exec path and host context
- Add network device detection and raw execution routing to aiBridge.cjs
(the primary Catty Agent command path), not just the MCP bridge
- Export getSessionMeta from mcpServerBridge for reuse in aiBridge
- Surface deviceType in Catty Agent system prompt host list so the AI
can identify which sessions are network devices
- Pass deviceType through buildSystemPrompt context
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: exempt network device sessions from client-side blocklist and update ACP context
- Add deviceType to ExecutorContext sessions type
- Skip renderer-side command blocklist for deviceType=network sessions
in shared toolExecutors.ts (not just main-process side)
- Update ACP agent context hint to mention network device sessions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only show network device mode toggle for SSH hosts
Telnet and local hosts don't support the network device execution path,
so hiding the toggle prevents users from enabling a broken configuration.
Serial hosts already use raw mode by default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: exclude Mosh sessions from network device raw execution path
Mosh uses a shell-backed PTY and cannot connect to vendor CLIs, so
network device mode should only apply to SSH and serial sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prefer session.protocol over metadata for Mosh detection
Mosh tabs report protocol:"ssh" in renderer metadata but "mosh" in
the main-process session object. Prioritize session.protocol (runtime
truth) to correctly exclude Mosh from network device raw execution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: suppress deviceType metadata for Mosh sessions
Mosh requires a shell-backed PTY and cannot connect to vendor CLIs,
so omit deviceType from AI-facing metadata when session is Mosh-backed.
This prevents the AI from being told to use vendor CLI syntax when the
actual execution path uses normal shell wrapping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use exit code 0 for network device sessions and hide toggle for Mosh
- Network device / serial sessions return exitCode: null from vendor
CLIs. Default to 0 instead of -1 so the AI doesn't misinterpret
successful commands as failures.
- Hide the network device mode toggle when Mosh is enabled, since
the setting is suppressed at runtime for Mosh sessions anyway.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve null exit codes and restrict raw mode to SSH/serial
- Preserve exitCode: null for network device sessions instead of
coercing to 0, so the AI knows exit status is unavailable rather
than seeing a misleading success code.
- Explicitly whitelist SSH/serial protocols for network device mode
instead of just excluding mosh, preventing local/telnet sessions
from accidentally entering raw execution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use UTF-8 encoding for SSH network device raw execution
execViaRawPty hardcodes latin1 for serial port data decoding. Add an
encoding option (default: latin1) and pass utf8 from SSH network
device call sites so multi-byte characters aren't corrupted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use host charset for serial port decoding instead of hardcoded latin1
- Extract charsetToNodeEncoding() to module scope in terminalBridge
- Serial sessions now read options.charset (from Host.charset) for
both terminal display decoding and AI command output
- Store serialEncoding on session object so exec paths can use it
- Pass encoding through all execViaRawPty call sites
- Default encoding changed from latin1 to utf8 (matches most modern
network equipment and is the safer default for CJK environments)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move serialEncoding declaration before session object creation
serialEncoding was referenced in the session object literal before its
const declaration, causing a TDZ ReferenceError that would crash every
serial connection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: tighten isNetworkDevice logic and clean up edge cases
- Align toolExecutors isNetworkDevice check with bridge logic: require
explicit SSH/serial protocol match instead of trusting deviceType alone
- Remove empty-string protocol match from isSshOrSerial in both bridges
to prevent local/unknown sessions from being treated as network devices
- Widen exitCode return type to `number | null` to match actual behavior
- Clear deviceType when enabling Mosh (incompatible combination)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update MCP server tool descriptions for network device sessions
The get_environment and terminal_execute tool descriptions only
mentioned serial/raw sessions for network devices. Updated to also
reference deviceType: network SSH sessions so external AI agents
(Claude, Codex) know about the new execution mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: include deviceType in get_environment and guard execViaChannel fallback
- Add deviceType to executeWorkspaceGetInfo session mapping and return
type so Catty Agent's get_environment tool matches MCP bridge output
- Guard both aiBridge and mcpServerBridge against falling through to
execViaChannel for network device sessions — network devices require
an interactive PTY and exec channels would produce broken behavior
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add charset setting to serial host configuration UI
Serial hosts now have a charset input in the Advanced section,
defaulting to UTF-8. The value is saved to Host.charset and used
by the serial decoder in terminalBridge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add charset to serial quick-connect modal with full pipeline
- Add charset input to SerialConnectModal (Advanced section)
- Thread charset through onConnect callback → handleConnectSerial →
createSerialSession → TerminalSession.charset
- Add charset field to TerminalSession interface
- Include charset in fallback host builder for quick-connect sessions
so createTerminalSessionStarters can pass it to startSerialSession
- Saved hosts also store charset via onSaveHost
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: constrain serial connect modal height with scrollable content
Modal content could overflow the viewport when Advanced section was
expanded. Add max-h-[85vh] to DialogContent with flex layout so the
content area scrolls while header and footer buttons stay visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: propagate charset through all serial session creation paths
- Add charset to startSerialSession type in global.d.ts
- Copy host.charset to TerminalSession in connectToHost serial path
- Copy host.charset in createWorkspaceWithHosts serial path
- Propagate session.charset in splitSession (both workspace and standalone)
- Propagate session.charset in copySession
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: propagate charset in remaining session creation paths
Add host.charset to connectToHost (non-serial), createWorkspaceWithHosts
(non-serial), and runSnippet session creation for consistency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: comprehensive performance optimization across UI and state management
- Replace Array.find/includes with Map/Set lookups for O(1) access in hot paths
- Add requestAnimationFrame throttling to all mousemove resize handlers
- Remove redundant forceUpdate + useSyncExternalStore double subscription
- Extract terminal search decoration config to module-level constant
- Pause server stats polling and resize handlers for hidden terminals
- Add timer cleanup for useEffect/useLayoutEffect with setTimeout
- Use useEffectEvent to stabilize effect callbacks and reduce effect re-runs
- Use useDeferredValue for QuickSwitcher search input
- Batch activeTabStore notifications with microtask coalescing
- Memoize sessionLogConfig and activityTrackedSessions to prevent child re-renders
- Use ref pattern for stable onTerminalDataCapture callback
- Skip TerminalLayer pre-warming when no sessions or workspaces exist
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: flush final resize value before canceling RAF
Apply the last computed size synchronously on mouseup/cleanup before
canceling the pending requestAnimationFrame. This prevents the final
drag delta from being dropped when mouseup fires before the queued
frame executes.
Addresses review feedback from codex on all 3 RAF-throttled resize
handlers: split resize, side panel resize, and SFTP column resize.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: initialize lastClientXRef on resize start to prevent click-collapse
Without initialization, a click on the resize handle without dragging
would use lastClientXRef=0, computing a large negative diff and
collapsing the column to minimum width.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert useDeferredValue for QuickSwitcher search
useDeferredValue can lag behind the actual input, causing quickResults
to reflect a stale query when the user types fast and presses Enter.
This is a correctness regression - the selected item may not match the
user's intent. The host list is typically small (<200), so synchronous
filtering is fast enough without deferral.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore runtime activity guard to prevent stale badge on tab switch
The pre-filtered activityTrackedSessions reduces subscriptions for
disconnected sessions, but removing the runtime shouldMarkSessionActivity
check introduced a race: between tab switch and effect re-subscription,
old listeners could mark the newly-focused session as unread. Restore
the activeTabIdRef.current guard inside the callback as a safety net.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: defer initialConnectDoneRef flag until auto-connect executes
Moving the flag inside the setTimeout callback prevents it from being
set when the timer is canceled by cleanup. Previously, if the effect
re-ran before the setTimeout(0) fired, the timer was cleared but the
ref was already true, permanently skipping the initial local connect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: capture resizingRef fields before setState updater
Destructure field/startX/startWidth from the ref upfront so the
functional updater closure never reads resizingRef.current after
it may have been cleared by handleResizeEnd.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove activeTabId from activityTrackedSessions to stabilize subscriptions
Depending on activeTabId caused subscriptions to tear down and recreate
on every tab switch, resetting the ChunkedEscapeFilter mid-sequence and
producing false unread badges. The runtime guard via activeTabIdRef
already handles the active-tab check, so pre-filtering only needs to
exclude disconnected sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fetch server stats immediately when tab becomes visible again
Use hasFetchedRef to distinguish first connect (2s delay for connection
stabilization) from tab resume (immediate fetch). Prevents showing
stale CPU/memory data for 2 seconds after switching back to a terminal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore cold-start prewarm and reset network stats on tab resume
1. Revert shouldPrewarm guard - TerminalLayer should always prewarm
after 1.2s regardless of session/workspace count, as the purpose is
to hide lazy-load latency before the user opens their first terminal.
2. Reset netRxSpeed/netTxSpeed to 0 when resuming a hidden terminal
tab. The backend computes network throughput as a delta from the
previous sample, so the first fetch after a long hidden interval
would show artificially low throughput averaged over the gap.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset hasFetchedRef on disconnect and preserve built-in theme precedence
1. Clear hasFetchedRef when connection drops so reconnects get the 2s
stabilization delay before first stats fetch.
2. Reverse theme merge order in themeById Map so built-in themes are
written last and take precedence over custom themes with duplicate
IDs, matching the original find() semantics and other resolution
sites (customThemeStore.getThemeById, Terminal.tsx).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: also clear per-interface network speeds on tab resume
Reset rxSpeed/txSpeed on each netInterfaces entry in addition to the
aggregate values, so the network hovercard doesn't show stale
throughput while waiting for the first fresh poll after resume.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset capture ref on retry and skip warmup for established connections
1. Reset terminalDataCapturedRef in handleRetry() so log capture works
for retried sessions (retry doesn't change sessionId, so the effect
that resets the ref never re-runs).
2. Track connection start time to skip the 2s warmup delay when a tab
becomes visible for a connection that was already established while
hidden. Only apply the warmup for truly fresh connections (<2s old).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent overlapping stats requests and track connection time while hidden
1. Add fetchInFlightRef guard to prevent concurrent getServerStats
requests that could race and corrupt baseline CPU/network data.
2. Move connectedAtRef initialization before the isVisible check so
connections that complete while the tab is hidden record their
start time. This ensures the warmup delay is correctly skipped
when the tab becomes visible for an already-stable connection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset fetchInFlightRef on disconnect to unblock reconnect stats
A pending getServerStats request from a previous connection could keep
fetchInFlightRef set, causing the reconnected session's initial fetch
to be skipped until the old request timed out.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clear fetchInFlightRef when tab becomes hidden
Ensures the resume fetch isn't blocked by an in-flight request from
the previous visible cycle. Any stale response from the old request
will be quickly overwritten by the fresh immediate fetch on resume.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use generation counter to invalidate stale stats responses
Replace fetchInFlightRef with a generation counter that increments on
each fetch. Stale responses from before a hide/show cycle are discarded
by comparing the captured generation against the current value, fully
preventing pre-hide requests from overwriting zeroed network stats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: increment fetch generation on effect setup to invalidate in-flight requests
The generation was only incremented inside fetchStats, but the resume
setTimeout hadn't fired yet when old responses arrived. Incrementing
at effect setup time ensures any pre-hide in-flight request is
immediately stale, preventing it from overwriting zeroed network stats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace workspace pane border with text dimming for unfocused panes
Replace the 2px primary-color border and Tailwind ring with a subtler
focus indicator: unfocused panes reduce xterm canvas opacity to 70%,
making text slightly dimmer without adding visual clutter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use visibility:hidden for terminal caching and restore focus on tab switch
- Replace display:none with visibility:hidden for TerminalLayer and
workspace panes to preserve xterm canvas state across tab switches
- Restore focus to the correct pane when terminal layer becomes visible
again, preventing opacity flash from :focus-within CSS
- Reduce autocomplete popup box-shadow intensity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add local spec files for commands missing from @withfig/autocomplete
(journalctl, yum, awk) and load them with priority over the upstream
package. Also enforce strict per-host isolation for command history —
previously cross-host matching by OS leaked host-specific commands
(e.g. cd /cq/) into unrelated sessions.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add session activity indicator and store
Introduce a SessionActivityStore (useSyncExternalStore) to track which tabs/workspaces have unread terminal activity. TerminalLayer now strips terminal control sequences, listens for session data, and marks tabs as active when not focused; it also clears activity on focus change and prunes stale IDs. TopTabs consumes the activity map to render a breathing activity dot on session/workspace tabs and adjusts the workspace tab layout to show the dot next to the pane count. Add CSS animation for the activity indicator.
* fix: buffer incomplete escape sequences across data chunks
Add ChunkedEscapeFilter to carry partial ANSI/OSC escape-sequence
tails between successive data chunks, preventing false-positive
activity badges from split control sequences on busy sessions.
Also fix missing trailing newline in sessionActivity.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove 256-byte cap on pending escape sequence tails
Long OSC sequences (e.g. clipboard/title payloads) can exceed 256
bytes. Removing the cap ensures they are fully buffered across
chunks instead of being misclassified as printable output.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: buffer OSC tails that end on bare ESC awaiting backslash
OSC sequences terminated with ESC\ can split at the ESC boundary.
Extend the incomplete tail regex to also match an in-progress OSC
sequence ending with ESC (awaiting the closing backslash).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
之前 handleSubDirSelect 只写最后一级名称(如 ca-certificates/),
导致 cd /usr/local/share/ca-certificates/ 变成 cd /ca-certificates/。
修复:从面板的 dirPath 构建完整路径,用 Ctrl+U 清除当前输入,
重写完整命令(如 cd /usr/local/share/ca-certificates/)。
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent double-click update crash and improve update UX (#522)
- Add state guards to prevent checkForUpdates during active download
- Disable "Check for Updates" button during checking/downloading/ready
- Make version badge trigger in-app download instead of opening GitHub
- Change error toast action from "Open Releases" to "View in Settings"
- Add "Download Now" button in system settings as primary action
- Keep GitHub release link as secondary fallback in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset download state when downloadUpdate() rejects
Clears _isDownloading and broadcasts error status on catch so the
update UI does not get stuck after a failed download attempt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only show Download Now after a completed update check
Prevents downloadUpdate() from being called with stale cached state
before electron-updater has run checkForUpdates(), avoiding a
"Please check update first" error.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use correct broadcast function and prime updater before download
- Replace undefined broadcastUpdateStatus with broadcastToAllWindows
- Call checkForUpdate before downloadUpdate to ensure electron-updater
has populated update metadata
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use correct error payload field and guard unsupported platforms
- Use { error: ... } instead of { message: ... } in download error
broadcast to match renderer expectations
- Bail out of startDownload when checkForUpdate returns unsupported
or throws, instead of entering a failing download path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard startDownload against in-flight and no-update check results
Bail out when checkForUpdate returns checking, not-available, or
unsupported states to prevent calling downloadUpdate prematurely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove duplicate error broadcast and fallback to releases on unsupported
- Remove redundant broadcastToAllWindows in download catch (global
error listener already handles it)
- Open release page instead of silently returning when platform
does not support auto-update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: check supported before available to ensure release page fallback
Unsupported platforms return { available: false, supported: false },
so the supported check must come first to open the release page
instead of silently returning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip download when update is already ready or downloading
Guard against re-downloading when checkForUpdate returns ready or
downloading sentinel, preventing overwrite of valid install state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fallback to release page when electron-updater reports no update
When GitHub API found an update but electron-updater does not,
open the release page instead of silently doing nothing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: expose advanced AI model parameters in provider settings (#532)
Add collapsible "Advanced Parameters" section to provider config with
optional max_tokens, temperature, top_p, frequency_penalty, and
presence_penalty fields. Parameters are merged into streamText() calls
only when explicitly set, otherwise provider defaults apply.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use maxOutputTokens instead of maxTokens for ai@6 SDK
The streamText CallSettings in ai@6 expects maxOutputTokens, not
maxTokens. Without this fix the user's max_tokens setting is silently
ignored.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: allow negative penalty input and clamp params on save
- Use raw string state for penalty fields so typing "-" is not
discarded before the digit is entered
- Clamp all parameters to valid ranges on save (temperature 0-2,
topP 0-1, penalties -2 to 2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use raw string state for all numeric advanced param inputs
Prevents intermediate text like "0." from being normalized to "0"
during keyboard entry of decimal values for temperature, topP, and
maxTokens fields.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clamp max_tokens to minimum of 1 after rounding
Prevents Math.round(0.4) = 0 from being persisted and causing
streamText to reject with "maxOutputTokens must be >= 1".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reject non-finite max_tokens before persisting
Guard with Number.isFinite to prevent Infinity from being stored
and forwarded to streamText.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add global SFTP bookmarks shared across all hosts (#529)
- Add global bookmark support with separate localStorage storage
- Global bookmarks appear on all hosts with a globe icon indicator
- "+Global" button in bookmark popover to save path as global
- Global bookmarks sorted before host-specific bookmarks
- Improve SFTP error display: use Unplug icon, refined styling,
auto-expand connection logs on error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: toggle bookmark correctly removes global-only bookmarks
When a path is only globally bookmarked, the toggle button now
removes the global bookmark instead of creating a duplicate host one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(sftp): add "Copy file path" to right-click context menu (#507)
Add a context menu item that copies the full remote file/directory path
to clipboard using navigator.clipboard.writeText(). Works for both
files and directories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: 使用 joinPath 构建复制路径,修复 Windows 路径分隔符问题
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: joinPath 去除 Unix 路径尾部多余斜杠
避免 currentPath 带 trailing slash 时产生双斜杠路径(如 /var/log//syslog)。
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed did-finish-load polling trigger that called markRendererReady via DOM child count checks.
Kept deferred show behavior based on:
ready-to-show
renderer-ready IPC from renderer
timeout fallback (dev and prod values unchanged)
Instead of creating a new BrowserWindow on each user click, the settings window is now:
1. Pre-warmed silently 3 s after app startup (showOnLoad: false)
2. Hidden instead of destroyed when the user closes it
3. Instantly shown/focused on subsequent opens
When smooth scrolling is enabled (smoothScrollDuration: 120ms) and
an AI agent produces high-throughput output, the scroll animation
can't keep up with incoming data, causing the viewport to get stuck
mid-buffer. Users can't scroll to the bottom or Ctrl+C to interrupt.
Default to false. Users who prefer smooth scrolling can still enable
it in Settings > Terminal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ssh2 emits conn.once("close") before stream.on("close") during
transport drops. The conn.close handler was sending exit + deleting
the session, then stream.close would send a second misleading exit.
Now stream.close checks sessions.has() before sending exit, while
still flushing the data buffer unconditionally. This ensures:
- Buffer flush always happens (no data loss)
- Exit event is sent exactly once
- Transport errors are correctly reported
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Change authMethods.length condition from > 1 to >= 1 so the
dynamic authHandler (which includes 'none' probing) is always used,
even when only keyboard-interactive is available. Fixes the
passwordless embedded device case when no keys/agent are discovered.
P1: Add PPK encryption detection to isKeyEncrypted() — check for
"Encryption:" header in PuTTY PPK format. Without this, encrypted
.ppk files were treated as unencrypted and attempted without a
passphrase, failing silently instead of triggering the passphrase
retry flow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Add "none" to the agent-mode simple array auth path so passwordless
devices work even when agent forwarding is configured.
P1: Extend looksLikePrivateKey() to recognize PuTTY PPK format
("PuTTY-User-Key-File" prefix) so PPK keys in ~/.ssh/ are not
incorrectly filtered out.
P2: Add stat().isFile() check before readFile() in all key discovery
paths to skip FIFOs, sockets, directories, and other non-regular files
that would block readFile() indefinitely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Transport errors on established sessions now surface correctly.
The stream.on("close") handler (which fires before conn close and
after buffer flush) checks session._transportError and sends exit
with exitCode:1 and the error message instead of a misleading
exitCode:0 "closed".
P1: Add looksLikePrivateKey() content validation to all key discovery
functions. Files matching id_* that don't start with "-----BEGIN" or
"openssh-key-v1" are skipped, preventing non-key files from being
passed to ssh2 as privateKey (which would abort connect before
password/agent fallback could run).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex P2: when a transport error (ECONNRESET) arrives after the session
is established, the error handler was immediately sending netcatty:exit,
causing preload to remove data listeners before the stream close handler
could flush the 8ms data buffer. Users would lose the last chunk of
terminal output.
Now the error handler stores the error message on the session object
(_transportError) instead of sending exit immediately. The close handler
(which fires after stream close + buffer flush) checks for this flag
and sends the exit event with the transport error info.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Medium: Close handler now checks sessions.has(sessionId) before sending
netcatty:exit, preventing a misleading exitCode:0 "closed" event after
the error handler already reported the real transport failure.
Medium: Array-based auth path in buildAuthHandler now includes "none"
as the first method, matching the dynamic handler behavior.
Low: Set lastAttemptedLabel to "none (no credentials)" so the rejection
message is consistent with the initial onAuthAttempt callback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the fixed DEFAULT_KEY_NAMES array ("id_ed25519", "id_ecdsa",
"id_rsa") with a directory scan using /^id_[\w-]+$/ regex, matching
Tabby's PrivateKeyLocator behavior. This discovers keys like
id_ed25519_work, id_dsa, or any custom-named key automatically.
Preferred keys (ed25519, ecdsa, rsa) are still tried first, followed
by any additional keys found in alphabetical order.
Applied to both sshBridge.cjs and sshAuthHelper.cjs (all four
key discovery functions + the get-default-keys IPC handler).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore unconditional 'none' auth as the first method tried. Per
RFC 4252, the 'none' request is the standard way for clients to
discover which auth methods the server supports. It also enables
passwordless login on embedded devices (#482).
This matches the behavior of OpenSSH (which always sends 'none'
first) and Tabby (which unconditionally adds { type: 'none' } as
the first element of allAuthMethods). Most SSH servers do not count
'none' toward MaxAuthTries per the RFC.
Applied to both the main SSH authHandler and the shared
buildAuthHandler used by SFTP/chain/exec connections.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex review identified P1 issues: automatic 'none' auth before any
other method can exhaust MaxAuthTries on hardened servers, breaking
connections that previously worked. The 'none' auth support for
embedded devices should be a user-facing option, not automatic.
This commit reverts the 'none' auth additions while keeping the
crash prevention fixes (settled guard, conn.destroy, error wrapping).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: Only try 'none' auth when no explicit credentials (password/key/agent)
are configured. Avoids wasting an auth attempt on servers with low
MaxAuthTries.
P2: Post-settle errors on active sessions now send netcatty:exit to the
renderer instead of being silently swallowed, so transport failures
(keepalive timeout, ECONNRESET) are correctly reported as errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SSH protocol's 'none' auth method allows login without any
credentials — common on embedded devices (routers, switches) where
root has no password. ssh2 tries this by default, but Netcatty's
custom authHandler and buildAuthHandler overrode the default behavior
and never attempted 'none', making it impossible to connect to these
devices.
Now both authHandlers try 'none' as the first method (before any
other auth) on the initial call (methodsLeft === null). If the server
accepts it, the connection succeeds immediately. If rejected, the
normal auth flow continues with publickey/password/keyboard-interactive.
This is the root cause of #482: the user's embedded device needed
'none' auth, but Netcatty never tried it, then the auth failure +
ECONNRESET combination crashed the app.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When connecting to embedded devices with legacy algorithms and no password,
the SSH connection could crash the app with an uncaught ECONNRESET exception.
Three fixes:
1. Guard against duplicate error handling in conn.on("error") — once the
promise is settled, late errors (e.g. ECONNRESET after auth failure)
are logged but no longer re-reject or re-notify the renderer.
2. Destroy the SSH connection on error/timeout to prevent the underlying
TCP socket from emitting further uncaught errors.
3. Wrap non-auth errors in startSSHSessionWrapper with clean Error objects
so Electron's ipcMain.handle can serialize them back to the renderer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR #449 set npmRebuild: false in electron-builder.config.cjs to fix a
Linux architecture mismatch. But this also disabled native module
recompilation for macOS and Windows builds, causing node-pty to ship
with the wrong ABI (Node.js instead of Electron). On macOS, this
manifests as "posix_spawnp failed" when opening a local terminal.
Restore npmRebuild: true. Linux builds are unaffected because they
already run ensure-node-pty-linux.sh before packaging with explicit
npm_config_arch, and the redundant rebuild uses the same arch setting.
User confirmed: 1.0.62 works, 1.0.63 (first release after #449) fails.
Closes#474
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The remote file listing mapper in useSftpDirectoryListing.ts was
dropping the `permissions` field returned by the backend. This caused
the permissions dialog to show all checkboxes unchecked (000) and the
file list to show "--" in the permissions column.
One-line fix: add `permissions: f.permissions` to the mapped object.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add title attribute to the file name span so truncated names reveal
their full text via native browser tooltip on hover.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: Wrap keyboard-interactive handlers in SSH chain, SFTP chain, and
SFTP main connections to emit "waiting for user input..." and "user
responded" progress events, matching the SSH main connection behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: Remove premature onAuthAttempt calls from buildAuthHandler's array
branch — methods are listed before connect(), making logs inaccurate.
P2: Handle "waiting for user input..." and "user responded" as literal
log messages, not as "Trying X..." format, in both SSH and SFTP.
P3: Clear connectionLogs after successful SFTP connect so directory
navigation doesn't replay stale auth transcript in the loading overlay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P2: Emit onAuthAttempt notifications from buildAuthHandler's array
branch so single-method SFTP connections (e.g. password-only) show
auth method logs in the connection panel.
P3: Show connectionLogs in the cached-files loading overlay so repeat
connections still display auth progress during reconnect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- add explicit Linux/macOS guard in server-stats hook
- return UNSUPPORTED_OS from ssh bridge when uname is not Linux/Darwin
- fail fast when stats payload cannot be parsed to avoid futile polling
- wire Terminal to pass supported-OS hint to useServerStats
P2: Guard SFTP progress callback with navSeqRef check to prevent stale
auth logs from leaking into a reused tab after retry/disconnect.
P3: Reset connectionLogs when connecting to local filesystem, avoiding
stale remote auth logs showing in the local pane.
P3: Emit 'connected' progress event when the final SFTP SSH session
is ready, so the log confirms the connection completed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- auto-detect remote OS in sshBridge using uname -s
- add macOS stats collection path (CPU, memory, swap, processes, disk, network)
- keep existing Linux stats pipeline and parsing logic
- remove Linux-only gating in useServerStats and Terminal display logic
- show server stats whenever connected (not restricted by host.os)
- add CPU hover fallback UI when per-core data is unavailable (e.g. macOS)
- update bridge type docs in global.d.ts to reflect cross-OS stats support
Add detailed authentication method logs to both SSH terminal and SFTP
connection flows, giving users visibility into which methods are tried,
rejected, or require input.
Backend (shared):
- sshAuthHelper buildAuthHandler: track lastAttemptedLabel, log method
rejections and "all methods exhausted" via onAuthAttempt callback
- sftpBridge: add sendSftpProgress helper, wire onAuthAttempt to both
chain and main buildAuthHandler calls, emit connecting/authenticating/
connected/error progress events via new IPC channel
Backend (SSH-specific):
- sshBridge: log method rejections in custom authHandler, log
keyboard-interactive prompt/response and all-methods-exhausted
IPC/Bridge:
- preload: register netcatty:sftp:connection-progress listener, expose
onSftpConnectionProgress in bridge API
- global.d.ts: add onSftpConnectionProgress type
Frontend (SFTP):
- types.ts: add connectionLogs to SftpPane
- useSftpConnections: subscribe to progress events during connect,
convert to human-readable log lines, accumulate in pane state
- SftpPaneFileList: show logs below spinner during connecting, show
expandable "Show logs" in error view with collapsible log panel
Frontend (SSH):
- createTerminalSessionStarters: format rejected methods with ✗ prefix
and "all methods exhausted" message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: serializeHostsToSshConfig now emits IdentityFile directives so
managed ssh_config sources preserve key paths on sync. Paths with
spaces are automatically quoted.
P2: Unquote IdentityFile paths during import — ssh_config allows
quoted paths for filenames with spaces, but the quotes were stored
literally and caused fs.readFile to fail.
P2: Clear identityFilePaths when applying an identity profile, and
only forward them at connection time when no vault key is selected.
Prevents stale local key paths from triggering unrelated passphrase
prompts after switching to a different credential source.
P1 (SFTP): Forward identityFilePaths for jump hosts in SFTP credentials.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1: Pass identityFilePaths for jump hosts in SFTP credentials so chain
connections can load IdentityFile keys for bastion hosts.
P2: When the passphrase dialog is skipped or times out (not just
cancelled), clear the encrypted key and continue to the next identity
file. Previously skip/timeout fell through and left the encrypted key
in connOpts, causing the same stall this feature is meant to fix.
Applies to all 4 identity file loading paths (SSH chain, SSH main,
SFTP chain, SFTP main).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the manual-only text input with a file picker button that opens
the system file dialog (showOpenDialog with showHiddenFiles enabled so
~/.ssh/ keys are visible). Users can still type a path manually or use
the browse button.
Changes:
- electron/main.cjs: add netcatty:selectFile IPC handler
- electron/preload.cjs: expose selectFile on bridge
- global.d.ts: add selectFile type
- HostDetailsPanel.tsx: add FolderOpen browse button next to path input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add "Local Key File" option in the host credential type selector.
Users can specify local SSH key file paths (e.g. ~/.ssh/id_ed25519)
as an alternative to selecting a key from the vault. This is the
primary UI for keys imported via SSH config's IdentityFile directive.
UI behavior:
- Credential selector now shows three options: Key, Certificate,
Local Key File
- Local key file paths are displayed as a list with delete buttons
- Text input with Enter/Add support for adding new paths
- Selecting a vault key clears local key paths (and vice versa)
- Paths are stored as host.identityFilePaths and resolved at
connection time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SSH config import now parses the `IdentityFile` directive and stores
the file paths on the host as `identityFilePaths`. At connection time,
the SSH and SFTP bridges resolve these paths, read the key file content,
and use it for authentication — matching the behavior of OpenSSH and
Tabby.
If the key file is encrypted, a passphrase dialog is shown before
connecting. If the user cancels, the key is skipped and auth falls back
to other methods. If the file doesn't exist, a warning is logged and
the next key path is tried.
Changes:
- domain/models.ts: add `identityFilePaths` to Host interface
- domain/vaultImport.ts: parse `IdentityFile`, expand `~`, store paths
- global.d.ts: add `identityFilePaths` to NetcattySSHOptions and
NetcattyJumpHost types
- createTerminalSessionStarters.ts: pass identityFilePaths for both
main connection and jump hosts
- useSftpHostCredentials.ts: pass identityFilePaths for SFTP
- sshBridge.cjs: read identity files at connection time for both main
and chain connections, with encrypted key passphrase prompting
- sftpBridge.cjs: same for SFTP main and chain connections
Closes#463
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex P2: when the passphrase dialog is cancelled, the thrown
error now includes 'authentication' in the message and sets
level='client-authentication'. This allows the SFTP frontend's
isAuthError() check to recognize it and fall back to the password
retry path, preserving the key-first-then-password behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex P3: the passphrase modal was showing UUIDs or generic
placeholders like "private-key" / "hop-1-key" instead of the host
label or hostname. Now pass the human-readable label/hostname as
keyName so users can identify which key needs the passphrase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex P2: when using a proxy and an encrypted key, cancelling
the passphrase dialog cleaned up chain connections but leaked the
proxy socket in connectionSocket. Now explicitly destroy it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex P2: when the passphrase dialog is cancelled for the
final SFTP host, any already-open proxy/jump-host connections were
leaked because the throw bypassed the cleanup path. Now explicitly
end all chain connections before throwing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex P1 review: when the passphrase dialog is skipped or
times out, the encrypted key was left in connOpts.privateKey without
a passphrase. buildAuthHandler would still attempt it as publickey-user,
causing the same stall this PR fixes. Now delete connOpts.privateKey
in all non-success paths so auth falls back to password/keyboard-interactive.
Applies to SSH chain, SFTP chain, and SFTP main connection paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex review caught a P1 regression: when a multi-line snippet had
noAutoRun=false, the \r was appended before wrapping in bracketed
paste, causing shells to treat the Enter as pasted text instead of a
submit action. Now the bracketed paste wraps only the command text,
and \r is appended afterward so it is sent as a real keypress.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Codex review feedback: the snippet executor was registered on
mount before the session was ready, causing sidebar snippet clicks to
be silently dropped during the connecting/reconnecting window instead
of falling through to TerminalLayer's raw writeToSession fallback.
Now the executor is only published when status === "connected" and is
cleared back to null on disconnect so the fallback path is used for
sessions that aren't ready.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an SSH config specifies an encrypted IdentityFile for a jump host
(e.g. `IdentityFile ~/.ssh/id_ed25519` with passphrase protection),
the chain connection passed the encrypted key to ssh2 without a
passphrase. ssh2 failed to parse it and the auth hung until timeout,
with no user-visible prompt.
The same issue existed for SFTP connections using encrypted keys.
Now detect encrypted keys via `isKeyEncrypted()` before connecting and
prompt the user for the passphrase via the existing passphrase dialog.
If the user cancels, a clear error is shown. If skipped, auth falls
back to other methods (password, keyboard-interactive, default keys).
Closes#463
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add EHOSTDOWN, ENETDOWN, EPROTO, EPERM to the isNonFatalNetworkError
check. Also refactor to switch/case for readability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ssh2 emits multiple error events per failed connection (e.g. ECONNRESET
followed by "Connection lost before handshake"). Several code paths used
`.once("error")` which removed the listener after the first event,
leaving the second error unhandled and crashing the process via the
uncaughtException handler's re-throw.
Root cause: `runDistroDetection` ran unconditionally after connection
attempts (including failures), creating a new SSHClient to the same
unreachable host. Its `execCommand` used `.once("error")`, so the
second ssh2 error event had no listener and became an uncaught exception.
Fixes:
- execCommand: `.once("error")` → `.on("error")` with settled guard and
explicit `conn.end()` cleanup
- runDistroDetection: move into try block so it only runs after
successful connections
- portForwardingBridge: same `.once` → `.on` fix
- sftpBridge: add catch-all error listener after cleanup() removes the
pre-ready listeners
- main.cjs: suppress non-fatal SSH/network errors in uncaughtException
and unhandledRejection handlers as defense-in-depth (log to crash
bridge, do not re-throw)
Closes#452
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display
- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
connect input. The main regex requires brackets for IPv6+port, but now
falls back to detecting bare IPv6 (2+ colons, hex-only) when the
primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
when appending a port, preventing ambiguous displays like
"2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
hosts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: truncate long hostnames in connection dialog
Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: constrain connection dialog header so truncate works correctly
Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent action buttons from being squeezed by long hostname
Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: tighten bare IPv6 detection to avoid MAC address false positives
Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: avoid double-wrapping already-bracketed IPv6 hop labels
Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)
Inspired by #467 (@crawt).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use a regex ASCII test to detect lines where string indices equal cell
columns, skipping the buildStringToCellMap buffer walk entirely. Most
terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
a new array per scanLine call, reducing GC pressure during scrollback.
Inspired by #466 (@crawt).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache
Based on #464 by @crawt with fixes for review feedback:
- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
so onWriteParsed highlights land with fresh content instead of trailing
by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
translateToString() renders as spaces — keeps the map aligned with
the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change
Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard rAF callback against stale state and add debounce fallback
- Re-check enabled/alternate-buffer inside the rAF callback so a
pending frame doesn't resurrect decorations after the user disables
highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
(where Chromium suspends rAF) still get highlight updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent fallback timer from being cleared on rAF-pending path
- Don't clear debounceTimer at the start of immediate mode — in hidden
tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clear stale rAF in fallback timer and add alternate buffer guard
- Cancel the pending rAF and clear animationFrameId in the fallback
timer callback so hidden-tab refreshes don't leave animationFrameId
stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
matching the guard already present in the rAF callback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: extract executeRefresh to ensure all timer paths clear stale rAF
A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.
Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning
- Split triggerRefresh into two modes: "immediate" (rAF, for new output
and rule changes) and "debounced" (setTimeout, for scroll/resize),
eliminating the fixed 200ms delay after each write that caused visible
highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
prevent leaks and stale results.
* fix: include cell layout in highlight cache key to prevent misplaced decorations
Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.
Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve SSH chain connection hang and improve connection progress
- Fix Promise never settling when conn 'close' fires before 'ready'
during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
chain), including: connecting, key exchange, auth attempts, forwarding,
shell opening
- Surface auth method attempts (SSH agent, key names, password) in
progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility
Closes#463
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH
- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
don't render as 'Chain 1/1' (P3)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: mark shell-open failure as settled before closing connection
The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- TerminalLayer: remove bracket paste wrapping since we can't check
term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
bracket paste, so target sessions don't receive literal escape
sequences meant for the source terminal's paste mode state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
to term.modes, but respects user opt-out)
Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.
- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data
Fixes#455
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Close/destroy the SSH exec stream when the 5s timeout fires to
avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
non-root users fall through to the candidate probe chain instead
of incorrectly opening at root.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir
Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).
Fixes#458
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.
Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.
Fixes#456
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract error properties (code, errno, syscall, hostname, port,
signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.
These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- listLogs: stream-count newlines instead of reading entire file content
just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
avoiding O(file_size) memory/CPU for crash-loop scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
those are normal shutdowns, not crashes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mark re-thrown unhandledRejection errors so uncaughtException handler
skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
so partial delete failures still show remaining files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move crashLogBridge require before process error handlers so it is
available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
results when the user clicks different log files in quick succession.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- P1: Re-throw in unhandledRejection handler to preserve default fatal
semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
ensureLogDir() so crash logs work even before init() is called,
catching early startup failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The linux target config specified arch: ['x64', 'arm64'] for each format,
causing the x64 build job to also produce arm64 packages. These packages
contained x86-64 native modules (node-pty, serialport) since the x64 job
only rebuilds for x64. When artifacts were merged in the release job,
the incorrect arm64 deb from the x64 build could overwrite the correct
one from the arm64 build.
Remove arch from linux target config so the CLI flags (--x64/--arm64)
control which architecture is built per job.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Added environment variables for npm configuration to specify architecture in CI jobs for both x64 and arm64 builds.
- Implemented verification steps for downloaded Linux deb artifacts, ensuring both amd64 and arm64 versions are checked for integrity.
- Updated the `ensure-node-pty-linux.sh` script to resolve and verify serialport prebuilds, ensuring compatibility with the specified architecture.
- Enhanced the `verify-linux-deb-artifact.sh` script to allow optional deb file input and improved error handling for missing artifacts.
These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
The v1.0.62 amd64 deb/AppImage shipped with an aarch64 node-pty binary
because the build pipeline never explicitly locked the target architecture:
1. `electron-rebuild` was called without `--arch`, relying on auto-detection
2. electron-builder's default `npmRebuild` re-compiled native modules during
packaging, adding a second uncontrolled rebuild that could override the
prepare script's output
3. The x64 job did not set `npm_config_arch`, unlike the arm64 job
Changes:
- Pass `--arch` explicitly to `electron-rebuild` in ensure-node-pty-linux.sh
- Set `npm_config_arch: x64` in the x64 CI job (prepare + build steps)
- Disable `npmRebuild` in electron-builder config so only the prepare script
controls native module compilation
Closes#446, closes#448
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 'sftp.transfer.preparing' key to en.ts and zh-CN.ts so the
indeterminate transfer state shows localized text instead of the
raw i18n key.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove 65 debug console.log statements from production code
Remove bracketed debug traces ([SFTP navigateTo], [SFTPBackend],
[ManagedSourceSync], [AutoSync], [CloudSync], [Settings], etc.)
across 16 files. These were development logging that shipped to
production, creating noise in the console.
Also clean up dead variables left behind after log removal
(hotkeyDebug, results, verification reads).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove 43 unused exports and dead type definitions
Remove export keywords from symbols that are never imported outside
their defining file. Symbols still used internally keep their
definitions; symbols not used at all are removed entirely.
Removed entirely: TerminalLine, SessionLogsSettings, KDFParams,
SyncManagerConfig, GoogleTokenResponse, OneDriveTokenResponse,
getSyncStatusColor, resolveHostTerminalAppearance,
TerminalAppearanceDefaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When creating a new host, the global fontSize and theme were copied
into the host config. Since fontSizeOverride/themeOverride were not
set (undefined), the legacy detection logic treated the presence of
these values as an active override, locking the host to the global
values at creation time.
Stop copying fontSize and theme into new host configs. Without these
fields, resolveHostTerminalFontSize/ThemeId correctly falls back to
the current global setting, so hosts dynamically follow global
changes unless the user explicitly sets a per-host override.
Closes#424
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve SFTP tab connection key race condition in workspace mode
When rapidly switching focus between workspace panes, the single
pendingConnectionKeyRef could be overwritten before the tracking
effect mapped it to the created tab. This left tabs unmapped in
tabConnectionKeyMapRef, causing duplicate tabs on subsequent switches.
Replace the two-step async mechanism (pendingConnectionKeyRef + deferred
tracking effect) with a synchronous onTabCreated callback on connect().
The callback fires immediately after the tab ID is determined, before
any async SSH work begins, eliminating the race window entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: scope SFTP transfers to active connection and prevent stale session lookups
Two fixes for workspace focus-switching issues:
1. Transfer queue now filters by the active connection's host, so
switching focus between workspace panes only shows transfers
relevant to the currently displayed SFTP tab.
2. Move sftpSessionsRef.delete() before the async closeSftp() call
to close the race window where concurrent code could look up a
stale sftpId that the backend has already removed, causing
"SFTP session not found" errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: allow SFTP focus switching during file transfers
Active transfers should not block workspace focus-following. Transfers
run on their own sftpId independent of the active tab, and forceNewTab
preserves old connections, so switching focus is safe.
Only interactive operations (text editor, permissions dialog, file
opener, file watches) still block host switching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: refresh correct SFTP tab after transfer completes during focus switch
When a transfer completes while focus has switched to a different host,
refresh was targeting the currently active pane instead of the pane that
initiated the transfer.
Add optional tabId parameter to navigateTo() and refresh() so callers
can target a specific tab. Capture the tab ID at transfer start and use
it for the post-transfer refresh, ensuring the correct tab's file list
is updated regardless of which tab is currently focused.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: auto-reconnect SFTP when session is lost during navigation
When navigateTo() detected a missing or expired SFTP session, it
cleared the connection to null, showing the empty "Select a host"
state. Now it delegates to handleSessionError(), which triggers the
existing reconnection mechanism — keeping files visible while
reconnecting in the background.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: eliminate redundant stat calls before file transfers
Before this change, each file transfer performed 3-4 stat calls over
the network before the progress bar started moving:
1. startTransfer: stat to get file size (~100ms)
2. processTransfer: stat again if size was 0 (~100ms)
3. Conflict check: stat source file for mtime (~100ms)
4. Backend: stat again if totalBytes missing (~100ms)
Now:
- Use the source pane's cached file list for size and mtime (zero
network cost) instead of stat calls in startTransfer
- Store sourceLastModified on TransferTask so the conflict check can
use it directly instead of a redundant source stat
- Backend already skips stat when totalBytes is provided
This saves ~200-300ms of network round-trips per file before the
progress bar starts moving.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: show immediate progress feedback during transfer setup
The progress bar previously stayed at 0% for ~500ms-1s while the
backend acquired an isolated SFTP channel and waited for the first
data chunk. Users perceived this as the transfer being "sluggish".
Now start simulated progress immediately for all single-file
transfers (not just non-streaming ones). When the first real progress
update arrives from the backend, the simulation is stopped and real
progress takes over seamlessly. This gives instant visual feedback
that the transfer is in progress.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show accurate transfer progress instead of simulated values
The progress system had fundamental issues:
1. Simulated progress ran for ALL transfers including streaming ones,
creating fake progress that could reach 95% while real progress
was at 60%. The Math.max ratchet prevented regression, so users
saw inflated numbers.
2. Speed and remaining time were based on simulated data during the
setup phase, giving misleading estimates.
Changes:
- Only use simulated progress for non-streaming transfers (no real
progress callback available). Streaming transfers get real data.
- Remove the double ratchet (Math.max) from onProgress — the backend
already enforces monotonic progress, so the frontend should trust
the reported values directly.
- Show an indeterminate "preparing..." state during the setup phase
(channel acquisition, conflict check) instead of fake progress.
This honestly communicates that the transfer is starting.
- Hide speed and remaining time during the indeterminate phase since
no real data is available yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove dead progress simulation and non-streaming transfer code
startStreamTransfer is always available in Electron, so:
- Remove the non-streaming fallback path in transferFile() that read
entire files into memory with no progress reporting
- Remove startProgressSimulation / stopProgressSimulation and all
related refs (progressIntervalsRef, useSimulatedProgress,
hasStreamingTransfer)
- Remove the cleanup effect for progress intervals
All transfers now use the streaming path with real backend-reported
progress. The indeterminate "preparing..." state covers the setup
phase until the first real progress arrives.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: reduce SFTP transfer concurrency from 64 to 4
64 parallel SFTP read/write requests overwhelmed servers, causing
the first chunk response to be delayed by 46+ seconds. Reducing to
4 concurrent requests provides a responsive first progress update
(~1-2s) while still offering significant speedup over sequential
streaming.
Also adds timing logs to the transfer pipeline (processTransfer,
transferFile, downloadFile, uploadFile) to aid future diagnostics.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review findings from PR #443
Critical fixes:
- Fix refresh/navigateTo type signatures to include the tabId option
parameter — previously it was silently ignored, making tab-targeted
refresh non-functional
- Fix handleSessionError/reconnection in navigateTo for background tabs:
when called with explicit tabId, update that specific tab instead of
the active tab (which could be a different host)
- Fix uploadExternalFiles to capture and pass tabId for post-upload
refresh (was missing, only uploadExternalEntries was fixed)
Medium fixes:
- Restore Math.max monotonic ratchet on single-file onProgress to guard
against any non-monotonic backend values
- Add stat fallback in processTransfer to populate sourceLastModified
when file is not in the pane's visible file list (filtered/search)
- Adjust TRANSFER_CONCURRENCY from 4 to 8 as a better throughput/
responsiveness balance
Cleanup:
- Remove all debug timing logs (console.log with Transfer/downloadFile/
uploadFile prefixes) from both frontend and backend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent background tab navigation from rolling back active tab
Two P1 fixes from automated review:
1. navSeqRef race: navigateTo uses a per-side sequence counter, so a
background tab refresh would bump it and cause the active tab's
concurrent navigation to think it was superseded, restoring
previousPath instead of applying the fetched files. Now when
navSeqRef is superseded but tabNavSeqRef still matches, the fetched
result is applied (it's valid for this tab — only a different tab
bumped the counter).
2. Auto-follow tear down: needsNewTab only checked hostId, so same
host with different session-time overrides (port/protocol) would
reuse the tab and close the old SFTP session, aborting any
in-flight transfer. Now needsNewTab is true whenever the current
connection is alive, always preserving it with forceNewTab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When switching terminal tabs, the SFTP side panel would reset to the
initial directory (terminal cwd at open time), discarding user navigation.
Root cause: an effect cleared the initialLocation guard on every
visibility transition (isVisible false→true), causing the initialLocation
effect to re-navigate to the original path. Tab switches toggle
visibility, so every tab switch triggered the reset.
Remove the visibility-based guard reset. When the panel is truly closed,
the component unmounts and refs reset naturally. Tab switches only
hide/show the panel and should preserve navigation state.
Closes#440
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap download and decryption steps in separate try-catch blocks so
users see whether a sync failure is caused by a download error or a
decryption error (e.g. mismatched master passwords across devices).
Ref #436
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch deb package compression from default xz (LZMA) to gzip for
better compatibility with Deepin OS, which reports "lzma error:
compressed data is corrupt" during installation.
Closes#435
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use Unicode property escapes (\p{L}, \p{N}) in validation regex so
Chinese and other non-ASCII characters are accepted when creating or
renaming snippet packages. Remove the HTML pattern attribute that
doesn't support the Unicode flag.
Closes#434
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
electron-builder install-app-deps forks a child process via
remote-rebuild.js to run @electron/rebuild. The child's main()
has no .catch() handler, causing unhandled promise rejections
that exit with code 1 even after successful rebuilds.
Replace with direct `npx electron-rebuild` which runs in-process
and avoids the broken fork layer entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
electron-builder 26.7.0's remote-rebuild.js forks a child process to
run @electron/rebuild 4.0.x (ESM), but its main() has no top-level
.catch() handler. Unhandled promise rejections during async cleanup
cause exit code 1 even when all native modules rebuild successfully.
Switch to the legacy rebuilder which uses the app-builder binary
directly, bypassing the broken fork layer entirely.
Also revert the previous workaround in ensure-node-pty-linux.sh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The || echo approach may not catch all failure modes. Temporarily
disable errexit around npm run rebuild and check the exit code
explicitly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
electron-builder 26.7.0 returns exit code 1 even when native modules
rebuild successfully. Let the subsequent file existence checks catch
real failures instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add dismiss option for disconnected terminal dialog
* Refine terminal connection dialog visuals
* Polish terminal connection dialog layout
* fix: PowerShell AI exec markers visible and results not captured
PowerShell wrapped command was sent as 8 separate lines, causing:
1. Markers visible — PS echoes each line with prompt prefix, ^-anchored
filter regexes couldn't match
2. Line-by-line input — 8 \r\n = 8 Enter keypresses displayed sequentially
3. AI couldn't get results — end marker Write-Output format mismatch
between generation (format string) and filter (single-quote regex)
Combine into 2 lines (like posix) and use inline regex matching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use whole-line deletion to strip PowerShell __NCMCP_ marker echoes
PowerShell echoes each input line with the PS prompt prefix (e.g.
`PS C:\...> Write-Output '__NCMCP_..._S'; $env:PAGER=...`), so the
previous per-fragment substitutions left residual content visible in
the terminal after partial replacement.
Replace all PowerShell-specific fragment regexes with a single
whole-line regex that deletes any line containing __NCMCP_, regardless
of leading PS prompt or shell variant.
* fix: apply whole-line deletion to stripMarkers in ptyExec for Catty Agent
Same root cause as preload.cjs: PowerShell echoes the entire wrapper
line with PS prompt prefix (e.g. `PS C:\...> $__NCMCP_rc = if ...`).
The previous regex only stripped from __NCMCP_ onwards, leaving the
PS prompt and partial variable name visible in the AI's stdout capture.
Use the same ^[^\r\n]*__NCMCP_[^\r\n]* whole-line pattern so Catty
Agent also receives clean output without PS wrapper residue.
* fix: use compact if/elseif/else syntax in PowerShell wrapper to prevent >> continuation prompt
PowerShell interactive PTY parses `if (cond) { } elseif ...` with
spaces around braces as a multi-line block, causing >> continuation
prompt after line 2 is submitted. Switch to compact no-space form
`if(cond){...}elseif(...){...}else{...}` which PowerShell evaluates
as a complete expression on a single line.
Also remove the $global:LASTEXITCODE=0 reset on line 1 since it
clobbers $? before line 2 runs, making the -not $? fallback unreliable.
* fix: proper line-level buffering for PowerShell marker filter + remove >> trigger
preload.cjs:
- Replace chunk-based filterMcpMarkers with per-session filterMcpChunk
that buffers trailing fragments across PTY data events. Previously,
if __NCMCP_ was split across two IPC chunks (e.g. chunk1 ends with
'__N', chunk2 starts with 'CMCP_...'), neither chunk matched the
guard and both leaked to xterm.js. Now the tail of each chunk is held
and prepended to the next chunk before line-level filtering.
- Clean up per-session buffers on netcatty:exit to prevent memory leaks.
ptyExec.cjs:
- Replace if($LASTEXITCODE){...}elseif...else{...} with a brace-free
arithmetic expression: [int](-not $?) -bor [Math]::Abs([int]$LASTEXITCODE)
This eliminates the >> PowerShell continuation prompt that was triggered
by the interactive parser treating the if-block as an incomplete statement.
* fix: simplify PowerShell Line 2 to bare Write-Output to eliminate >> prompt
Any expression with operators, method calls, or variable assignment
can trigger PowerShell interactive continuation mode (>> prompt).
Use the absolute minimum: just Write-Output with $LASTEXITCODE interpolated
directly. This cannot trigger >>. Null $LASTEXITCODE is handled gracefully
by the execViaPty receiver (defaults to exit code 0).
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: log file name and use local time
* fix: improve SSH txt log sanitization with ANSI/OSC
* fix: log file name and use local time(update)
---------
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
* fix: respect global terminal appearance settings
* feat: add reset to global terminal appearance
* fix: preserve legacy host appearance overrides
* fix: show legacy appearance reset controls
* refactor: reorder terminal global reset actions
* refactor: present global theme as theme option
* refactor: present global font as font option
* feat: inline approval gate for tool execution
Replace SDK-level needsApproval with Promise-based approval gate inside
tool execute functions. The SDK stream stays alive while the UI shows
inline approve/reject buttons on ToolCall blocks.
Changes:
- Add approvalGate.ts: Promise-based approval system with event listeners
- tools.ts: requestApproval() inside execute for confirm mode
- tool-call.tsx: inline approval buttons and keyboard shortcuts
- ChatMessageList.tsx: subscribe to approval events, render approval UI
- useAIChatStreaming.ts: remove old useToolApproval hook integration
- AIChatSidePanel.tsx: remove old approval hook, clean up unused destructuring
- systemPrompt.ts: update confirm mode to not ask for text confirmation
- preload.cjs: filter pager env var prefixes from terminal display
- mcpServerBridge.cjs: add approval gate for ACP/MCP write operations
- aiBridge.cjs: wire IPC for MCP approval response and main window getter
- preload.cjs: add onMcpApprovalRequest/respondMcpApproval APIs
* fix: scope approval gate by chatSessionId and replay for late subscribers
Address Codex PR review comments:
- Add chatSessionId to ApprovalRequest for session isolation
- Scope clearAllPendingApprovals(chatSessionId?) to only clear
approvals belonging to the target session
- Add replayPendingApprovals() so late-mounting ChatMessageList
picks up approvals that fired while unmounted
- Scope MCP clearPendingApprovals in aiBridge cancel handler to
effectiveChatSessionId instead of clearing all
- Pass chatSessionId through MCP approval IPC flow
* chore: remove old approval flow code
- Delete useToolApproval.ts (unused hook)
- Delete InlineApprovalCard.tsx (replaced by ToolCall inline buttons)
- Remove stale comments referencing old hook in AIChatSidePanel
- Remove unused ai.chat.toolApprovalTitle i18n key from en/zh-CN
* fix: session-scoped approval gate and MCP replay survival
- handleStop passes activeSessionId to clearAllPendingApprovals
- setupMcpApprovalBridge stores MCP approvals in pendingApprovals map
so they survive ChatMessageList unmount/remount cycles
- ChatMessageList accepts activeSessionId prop and filters standalone
MCP approval blocks to the current session only
- AIChatSidePanel passes activeSessionId to ChatMessageList
* fix: filter PTY exec marker echoes and exit code lines from terminal
Extend filterMcpMarkers in preload.cjs to strip all shell-visible
artifacts from AI command execution:
- Echoed printf start marker: printf '%s\n' '__NCMCP_..._S'
- Echoed exit code restoration: (exit $__nc)
- PowerShell: Write-Output, $global:LASTEXITCODE, $__nc assignment
- Fish: set __nc $status
- Cmd: echo __NCMCP_...
- Widen guard to also trigger on __nc and PAGER=cat strings
* fix: scope SDK approvals, deny MCP on no renderer, fix memo comparator
- createCattyTools accepts chatSessionId and passes it to
requestApproval so SDK approvals can be matched by
clearAllPendingApprovals(activeSessionId) on stop
- useAIChatStreaming passes sessionId to createCattyTools
- mcpServerBridge: deny (resolve false) when no renderer window is
available instead of auto-approving, preserving confirm mode safety
- ChatMessageList: add activeSessionId to React.memo comparator so
switching sessions triggers re-render for correct MCP approval filter
* fix: MCP listener lifecycle, approval timeout, and UI sync on stop
- Move setupMcpApprovalBridge from ChatMessageList to AIChatSidePanel
so the IPC listener survives tab/panel switches
- Add 5-minute auto-deny timeout to requestApproval to prevent
indefinite isStreaming hangs when user walks away
- Add onApprovalCleared listener system: clearAllPendingApprovals now
notifies UI subscribers so ChatMessageList removes stale cards
- ChatMessageList subscribes to onApprovalCleared to sync local state
* fix: main-process approval timeout and full tool args in payload
- Add 5-minute auto-deny timeout to requestApprovalFromRenderer
matching the renderer-side requestApproval behavior
- Forward all tool params (excluding chatSessionId) to approval UI
instead of cherry-picking command/input/path, so sftpRename
oldPath/newPath and other tool-specific args are visible
* fix: move MCP bridge to TerminalLayer, narrow terminal filter guard
- Move setupMcpApprovalBridge from AIChatSidePanel to TerminalLayer
so the IPC listener stays alive regardless of side panel tab.
AIChatSidePanel only mounts when activeSidePanelTab==='ai'.
- Narrow preload.cjs filter guard back to __NCMCP_ only, preventing
false-positive stripping of user scripts containing __nc or PAGER=cat
* fix: eliminate PTY wrapper echo leakage and duplicate prompts
- Posix wrapper now emits 2 lines instead of 4: start marker + command
on line 1 (joined with ;), end marker + exit on line 2. This
eliminates the duplicate prompt echo from the separate start marker.
- Rename __nc to __NCMCP_rc in all shell variants (posix/fish/powershell)
so every wrapper variable contains the __NCMCP_ prefix. The preload
guard `data.includes("__NCMCP_")` now reliably catches ALL wrapper
artifacts regardless of chunk boundaries.
- Update all filterMcpMarkers regex patterns to match the restructured
wrapper format and renamed variable.
* fix: sync main-process approval timeout with renderer UI cleanup
- When requestApprovalFromRenderer times out, send IPC event
netcatty:ai:mcp:approval-cleared to renderer so stale approval
cards are removed
- Add onMcpApprovalCleared preload bridge for the new IPC channel
- setupMcpApprovalBridge now subscribes to cleared events, removes
timed-out entries from pendingApprovals and notifies clearedListeners
so ChatMessageList drops the stale card
* fix: surface denied inline approvals as errors in UI
- Detect error or denial payloads ("error" string or "ok: false")
returned by tools when the user denies an execution
- Set isError: true on the tool-result message so the ToolCall UI
renders it as a failure (red/rejected) instead of a success (green)
* Add AI support for local terminal sessions
* Fix local AI session metadata and shell safety
* Fix local session cloning and multi-exec errors
* Refactor local shell detection helpers
* Fix local shell helper import path
* Fix CJS imports in renderer
* Use ESM local shell helpers in renderer
* Normalize local shell paths and platform metadata
* Add AI support for local terminal sessions
* Fix local AI session metadata and shell safety
* Fix local session cloning and multi-exec errors
* Refactor local shell detection helpers
* Fix local shell helper import path
* Fix CJS imports in renderer
* Use ESM local shell helpers in renderer
* Normalize local shell paths and platform metadata
StickToBottom was configured with initial="smooth", causing a visible
elastic scroll animation every time the chat panel remounted on tab
switch. Change to initial="instant" so the scroll position snaps
immediately without animation. Streaming and resize still use smooth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove padding around image in preview modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add zoom controls and constrain image preview modal size
- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: improve image preview with aligned controls, drag-pan, animation
- Put filename, zoom controls, and close button in a single flex row
so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use transform scale for smooth zoom animation
Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: allow drag at any zoom level and add reset button
- Remove zoom > 100 restriction on drag — image can be panned at any
zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace backdrop blur with box-shadow for image preview modal
Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf: use refs for drag state to avoid rerendering chat list
Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add aria-labels to image preview controls for accessibility
Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reset button restores drag position and stays enabled after drag
Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent stuck drag state on pointer cancel or lost capture
If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove zoom > 100 restriction on drag — image can be panned at any
zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Put filename, zoom controls, and close button in a single flex row
so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add click-to-preview for images in AI chat
Uploaded images in AI chat messages can now be clicked to open a
full-size lightbox preview. Clicking the overlay or the image again
dismisses it. Uses the existing Radix Dialog component.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use standard dialog style for image preview with close button
Replace transparent borderless overlay with proper windowed dialog that
has a background, border, and the built-in close button (X) in the
top-right corner. Remove focus ring that caused the blue border.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add title bar with filename and blurred backdrop to image preview
- Show filename in dialog header with border separator
- Add overlayClassName prop to DialogContent for per-instance overlay
customization (e.g. backdrop blur, custom background)
- Apply semi-transparent black background with backdrop-blur on overlay
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: align title and close button vertically in image preview
Adjust header padding and close button position so the filename and
X button sit on the same visual line.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle Windows spawn for Claude ACP bundled JS binary
On Windows, child_process.spawn does not interpret shebangs, so spawning
a .js file directly (like claude-agent-acp's dist/index.js) fails with
ENOENT. The @mcpc-tech/acp-ai-provider uses raw spawn() internally.
Change resolveClaudeAcpBinaryPath to return { command, prependArgs } so
that on Windows the resolved .js script is invoked via process.execPath
(Node) with the script path prepended to args. On macOS/Linux the
shebang works natively so the script is spawned directly as before.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use system Node instead of process.execPath on Windows
In packaged Electron builds, process.execPath points to the app binary
(e.g. Netcatty.exe), not a Node runtime. Additionally, main.cjs deletes
ELECTRON_RUN_AS_NODE at startup and the agent spawn handler blocks it
in DANGEROUS_ENV_KEYS.
Resolve the real `node` from PATH instead. If Node is not installed,
fall back to the bare `claude-agent-acp` command name so the system
can find the npm-generated .cmd wrapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use script path for display and probe version correctly on Windows
In discovery, when resolveClaudeAcpBinaryPath returns { command: node,
prependArgs: [scriptPath] }, use the script path for UI display and
dedup, and probe version with the full command (node script --version)
instead of running node --version.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: bundle claude-code-acp to prevent crash when binary is missing (#400)
When users select Claude Code in the AI module, the app spawns
`claude-code-acp` via ACP. Previously only the `claude` CLI was checked
during agent discovery, so if `claude-code-acp` was not on PATH the
spawn would fail with ENOENT and crash the Electron main process.
- Add `@zed-industries/claude-code-acp` as a bundled dependency
- Add `resolveClaudeAcpBinaryPath()` that checks PATH first, then
falls back to the npm-bundled binary (mirrors Codex pattern)
- Use the resolver in both the primary and fallback ACP provider paths
- Update agent discovery to detect agents via bundled ACP binary when
the standalone CLI is not installed
Closes#400
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add claude-code-acp and its deps to asarUnpack
In packaged Electron builds, files inside app.asar cannot be executed
by child_process.spawn. Add claude-code-acp and its runtime dependencies
to asarUnpack so the binary is accessible in production.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: migrate from deprecated claude-code-acp to claude-agent-acp
The @zed-industries/claude-code-acp package has been renamed to
@zed-industries/claude-agent-acp (bin: claude-agent-acp). Update all
references across the codebase:
- package.json: replace dep with @zed-industries/claude-agent-acp@0.22.2
- electron-builder.config.cjs: update asarUnpack entries, remove stale
deps (diff, minimatch) no longer needed by the new package
- shellUtils.cjs: update binary name and require.resolve path
- aiBridge.cjs: update acpCommand, ALLOWED_AGENT_COMMANDS, isClaudeAgent
- settings types, i18n locales: update command references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: implement real-time session logging via main process streams
Fixes#394. Session logs previously only captured ~55 lines (the
xterm serialize buffer) and were written only on session close. This
change intercepts terminal data in the main process and writes it to
a file stream in real-time, capturing the complete session output.
- Add sessionLogStreamManager.cjs: manages per-session write streams
with 500ms/64KB flush, supports txt/raw/html formats
- sshBridge: start stream on shell open, append on data, stop on close
- terminalBridge: same for local, telnet, mosh, serial sessions
- Thread sessionLog config from renderer settings through IPC options
- Skip old renderer-side auto-save when streaming is active
- Cleanup all streams on app quit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove stale renderer-side auto-save and async HTML finalization
- Remove dead renderer-side auto-save code (main process handles it)
- Make stopStream async, await writeStream finish before HTML conversion
- Use fs.promises for HTML read/write/unlink
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add option to auto-open sidebar on host connect
Closes#396
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only auto-open SFTP sidebar for SSH/Mosh connections
Use allowlist (ssh, mosh) instead of blocklist so telnet and other
non-SSH protocols don't trigger SFTP sidebar which would fail.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: support auto-open SFTP for Quick Connect / temporary sessions
Build a minimal Host from session data when hostId is not in the vault,
so Quick Connect sessions also trigger auto-open SFTP sidebar.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sync SFTP auto-open sidebar setting across windows via IPC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip local terminals and preserve username for temp sessions
- Don't fallback protocol to 'ssh' so local terminals are excluded
- Include session.username in synthesized Host for Quick Connect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: suppress known_hosts toast on auto-scan at startup
The auto-scan on first mount now runs silently — no toasts for missing
known_hosts file, no entries, or no new hosts. Users still see toasts
when manually clicking "Scan System".
Closes#398
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: wrap onClick handlers to avoid passing event as silent flag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: support multimodal attachments (images, PDFs, files) in AI chat
Previously uploaded images were displayed in the UI but never sent to
the AI model, and non-image files (PDF, text) were silently rejected.
- Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/*
- Rename ChatMessageImage → ChatMessageAttachment with filePath support
- Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent
- Fix ACP agent path: images inline, non-image files via local path hint
so ACP agents (Claude Code, etc.) read them with native file access
- Use Electron webUtils.getPathForFile() for reliable file path capture
- Compact user message bubble padding
Closes#294 (AI file upload issues)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show real tool names in AI chat instead of ACP wrapper names
- Unwrap ACP dynamic tool calls in serializeStreamChunk to extract
real tool name, args, and toolCallId from chunk.input
- Simplify MCP tool name prefixes (mcp__server__tool → tool)
- Pass toolCallId from ACP tool-call events to match tool results
- Prevent onToolResult from overwriting correct names with wrapper name
- Build toolCallNames map in ChatMessageList for tool result display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: backward-compatible fallback for legacy `images` field in chat messages
Persisted sessions may still have `images` instead of `attachments`.
Add `?? m.images` fallback in SDK message builder and renderer so
historical image attachments are not silently dropped after upgrade.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: broaden file type support and handle pasted files without path
- Accept all file types except video/audio (instead of allowlist)
so .json, .yaml, .sh, etc. are not silently rejected
- For ACP agents, save pasted/virtual files (no filePath) to temp
directory so the agent can still read them
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use managed temp dir for pasted ACP attachments
Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path
so pasted file attachments are tracked by the app's cleanup system.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete all GIF files (replaced by mp4/user-attachments)
- Update demo sections to use GitHub video attachments
- Add contributor avatars via contrib.rocks
- Add Star History chart
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.
Security measures:
- Only providers explicitly configured with http:// are allowed over HTTP
- HTTPS-configured providers cannot be silently downgraded
- Temporary HTTP permissions expire after 30s TTL
- Non-http/https schemes are explicitly rejected
- webSearchApiHost entries preserved from accidental expiry
Fixes#392
On Windows, `fs.promises.mkdir("E:\", { recursive: true })` throws
EPERM for drive root directories. When users save SFTP downloads to a
drive root (e.g. E:\file.txt), `path.dirname` returns "E:\" and the
subsequent mkdir fails. Fix by catching the error and verifying the
directory already exists before re-throwing.
Fixes#390
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements automatic three-way merge for cloud sync, replacing the
binary USE_REMOTE/USE_LOCAL conflict resolution. Same principle as
Git's merge algorithm.
After every successful sync, a "base snapshot" is saved (encrypted
with AES-256-GCM using the derived master key). When a conflict is
detected, the system performs per-entity merge by ID:
- Items added on one side → included
- Items deleted on one side (unchanged on other) → removed
- Items modified on one side only → take that version
- Both sides modified same item → prefer local
- One side deleted + other modified → keep modification
Additional improvements:
- Per-provider sync base to prevent cross-provider contamination
- Deep merge for nested settings (terminalSettings, customKeyBindings)
- Entity merge for array-valued settings (customTerminalThemes)
- KnownHost deduplication by (hostname, port, keyType)
- Chunked base encoding to avoid stack overflow on large vaults
- Base cleared on provider disconnect/reconnect
- Correct version numbering after multi-provider merge
Closes#378
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: auto-close tab when user actively exits a session
When a user intentionally exits a session (e.g. typing `exit`, `logout`,
or Ctrl+D), the tab is now automatically closed instead of showing the
"Start Over" disconnected page. This matches the behavior of macOS
Terminal and other popular terminal emulators.
Network errors, timeouts, and server-initiated disconnects still show
the disconnected page with the Start Over option, so users can reconnect.
In workspace mode, only the individual terminal pane is closed, not the
entire workspace.
Implementation:
- Backend bridges now include a `reason` field in exit events to
distinguish stream-level exits ("exited") from connection errors
("error"), timeouts ("timeout"), and connection closes ("closed")
- SSH bridge captures real exit code from stream "exit" event instead
of hardcoding 0
- Frontend auto-closes session only when reason is "exited"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback for auto-close feature
1. Pass exit event to onSessionExit in local shell path (line 757)
to prevent undefined access when checking evt.reason
2. Change Telnet socket close reason from "exited" to "closed" since
a clean socket close can also be server-initiated (idle timeout,
remote shutdown), not just user exit
3. Change Serial port close reason from "exited" to "closed" since
port close can be from device disconnect, not user action
Only SSH stream close and local/mosh process exit (node-pty onExit)
now use reason "exited", which correctly represents user-initiated exits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only mark SSH exit as "exited" when stream exit event fired
ssh2's stream "close" event fires whenever the channel closes, not
only on normal shell exit. If the network drops and the channel closes
without a preceding "exit" event, the reason was incorrectly set to
"exited", causing the tab to auto-close instead of showing the
disconnected/Start Over page.
Now tracks whether stream "exit" actually fired via a flag, and only
uses reason "exited" in that case. Otherwise falls back to "closed".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: classify mosh non-zero exits as errors
Mosh process exiting with a non-zero code typically indicates a
connection or auth failure. Mark these as reason "error" so the
disconnected/Start Over UI is shown instead of auto-closing the tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: treat SSH signal-terminated exits as disconnects
ssh2's stream "exit" event also fires for signal terminations (e.g.
SIGHUP from server idle timeout, SIGTERM from admin kill), where code
is null and signal is set. These are not user-initiated exits and
should show the disconnected/Start Over page.
Now only sets streamExited=true when there's a numeric exit code and
no signal present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: distinguish abnormal local PTY exits from user exits
Local shell terminated by signal or crashing on startup should show
the disconnected UI, not auto-close the tab. Now only marks as
reason "exited" when exitCode is 0 and no signal, matching the same
logic used for mosh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use signal presence to distinguish local shell exit reason
For local shells, non-zero exit codes are common in user-initiated
exits (e.g. typing `exit` after a failed command returns that
command's exit code). Use signal presence instead: signal means the
process was killed externally (show disconnected UI), no signal
means normal process exit (auto-close tab).
Mosh keeps exitCode-based logic since non-zero there indicates
connection/auth failure, not user exit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: treat non-zero exit code as success and include output on failure
- Non-zero exit codes (e.g. grep returning 1, ls on missing file) are
valid command results, not execution failures. Changed execViaPty and
execViaChannel to always return ok:true when the command actually ran.
- ok:false is now reserved for real failures: timeout, session gone,
stream not writable, etc.
- When ok:false, include any partial stdout/stderr in the error message
so the user and LLM can see what happened before the failure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: return stdout+exitCode for all completed commands, clean up dead code
- ptyExec: preserve original ok semantics (non-zero = ok:false) so MCP
server bridge callers (handleMultiExec, stopOnError) still work
- execViaChannel: null exit code (SSH disconnect) returns ok:false
- toolExecutors: Catty Agent always returns stdout+exitCode to the LLM
regardless of exit code, only treats real failures (timeout, disconnect)
as errors — with partial output included
- Remove dead code: executeTerminalSendInput, executeSftp*, executeMultiHost
- Clean up unused imports, bridge interface, ExecutorContext
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove sftp_list_directory, sftp_read_file, and sftp_write_file tools.
The AI can use terminal_execute with standard shell commands (ls, cat,
tee, etc.) which is more flexible, supports sudo/pipes/redirects, and
reduces tool choice complexity for the LLM.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add "paste only" option for snippets (no auto-execute)
Add a noAutoRun flag to snippets that pastes the command into the
terminal without appending a carriage return, so users can review
and edit before manually pressing Enter.
Applies to all snippet execution paths: snippet runner (new session),
keyboard shortcut, and startup command.
Closes#371
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use clearer wording "仅粘贴" instead of "仅上屏"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip onCommandExecuted for paste-only shortcut snippets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: persist noAutoRun on save and apply to Scripts panel clicks
- Include noAutoRun in handleSubmit serialization (was being lost)
- Pass noAutoRun through ScriptsSidePanel click handler to TerminalLayer
so paste-only snippets work from the Scripts panel too
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show real error message instead of [object Object]
When an error object (not a string or Error instance) reaches the
error display path, String(obj) produces "[object Object]". Now
extract .message from error-like objects, or JSON.stringify as fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard JSON.stringify fallback against undefined return
JSON.stringify(undefined) returns undefined (not a string), which would
crash classifyError().toLowerCase(). Add ?? 'Unknown error' fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use non-throwing fallback for error serialization
JSON.stringify can throw on circular objects or BigInt values. Wrap in
try-catch to avoid losing the original error and leaving the stream
stuck in a streaming state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove multi_host_execute tool — the AI can call terminal_execute for
each host individually, which is simpler, more reliable, and avoids
the hang issue where parallel remote commands block the stream.
Fix AI_MissingToolResultsError that occurs after user stops a stream
mid-tool-execution: when building SDK messages, skip orphaned tool
calls that have no matching tool result instead of including them
(which causes the SDK to reject the next message).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use Electron's screen.getDisplayMatching() to find which display the
main window is on, then center the settings window on that display's
work area. Previously the settings window used Electron's default
placement which could open on the primary display even when the main
window was on an external monitor.
Ref #294
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add skip TLS verification option for AI providers
Self-hosted AI endpoints (vLLM, text-generation-webui, etc.) often use
self-signed TLS certificates which Node.js rejects by default, causing
502 Bad Gateway errors. Add a per-provider "Skip TLS certificate
verification" checkbox that sets rejectUnauthorized=false on both
streaming and non-streaming requests.
Ref #294
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: surface real error message instead of generic 502 Bad Gateway
- Pass the actual bridge error message in statusText so Vercel AI SDK
shows the real cause (e.g. "HTTP is only allowed for localhost",
"URL host is not in the allowed list", TLS errors)
- Show real error details for 5xx provider errors instead of generic
"The AI provider returned a server error" message
Previously all connection-level errors were masked as "Bad Gateway"
making it impossible for users to diagnose configuration issues.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: pass server error body details through to the user
- Read HTTP error response body before resolving (was resolving before
body was read, losing the error detail)
- Parse OpenAI-compatible JSON error format to extract error.message
- Return error Response with body+statusText for non-2xx instead of
empty stream, so Vercel AI SDK shows the real server error
- Now users see e.g. "502 model not loaded" instead of just "Bad Gateway"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: widen link modifier key dropdown to prevent text wrapping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "fix: widen link modifier key dropdown to prevent text wrapping"
This reverts commit 1f756863910d7450c6ffd8c373ef156e90adcce7.
* fix: apply skipTLSVerify to model listing requests
ModelSelector.aiFetch() didn't pass providerId, so the provider-level
skipTLSVerify was not applied when refreshing/listing models. Add
skipTLSVerify as a direct parameter alongside the provider lookup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: keep error detail in Response body, not statusText
statusText only accepts single-line Latin-1 — multiline or non-ASCII
error messages from self-hosted gateways would throw TypeError before
the AI SDK could read them. Move detailed error to body instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: return JSON error body for AI SDK compatibility, fix FetchBridge type
- Wrap error responses in OpenAI-compatible JSON format so Vercel AI
SDK's failedResponseHandler extracts the message correctly instead
of showing a blank error
- Update FetchBridge type to match the expanded aiFetch parameter list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add ASCII statusText fallback for non-OpenAI SDK providers
Anthropic/Google SDKs fall back to Response.statusText when they can't
parse the error body. Add safe ASCII statusText alongside the JSON body.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a host explicitly disables keyword highlighting, global rules are
no longer applied to that terminal. Previously the OR logic
(globalEnabled || hostEnabled) meant per-host disable had no effect
when global highlighting was enabled.
Now: hostEnabled=false suppresses global rules; hostEnabled=undefined
inherits global setting (backward compatible).
Ref #294
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Log Format "Plain Text (.txt)" and Link modifier key "None (click
directly)" were wrapping to two lines due to narrow widths.
Closes#294 (dropdown text wrapping)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add web search and URL fetch tools for AI agent
Add web_search and url_fetch tools to Catty Agent, allowing the AI to
search the internet for current information and fetch webpage content.
- Support 5 search providers: Tavily, Exa, Bocha, Zhipu, SearXNG
- Settings UI with provider selection, API key encryption, and config
- web_search is conditional on config; url_fetch is always available
- Both tools are read-only and work in all permission modes (incl. observer)
- aiFetch skipHostCheck for AI tool requests to arbitrary URLs
- System prompt guidelines for when to use search/fetch
- i18n support (en + zh-CN)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address code review findings (SSRF, key exposure, state race)
- P1: Restore SSRF protection when skipHostCheck is true — still block
localhost, RFC1918, link-local, and cloud metadata endpoints; only
skip the domain allowlist for public HTTPS hosts
- P2: Move web search API key decryption to main process via dedicated
IPC handler, matching the existing provider key security model
- P2: Use configRef to avoid stale closure in async settings callbacks
that could overwrite newer user changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address second review — DNS rebinding, url_fetch approval, maxResults
- P1: url_fetch now requires approval in confirm mode (outbound GET is
a side effect that could exfiltrate data via query strings)
- P1: Add DNS resolution check when skipHostCheck is set — resolve
hostname and reject if any IP is private/loopback/link-local, blocking
DNS rebinding attacks against internal services
- P2: Slice search results after provider call to enforce maxResults
consistently (Zhipu and SearXNG ignore the limit parameter)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address third review — localhost/IPv6 SSRF, API key blur race
- P1: Block localhost/loopback when skipHostCheck is enabled — restructure
isAllowedFetchUrl to check private hosts first in the skipHostCheck path,
preventing access to local services on allowlisted ports
- P1: Handle IPv6 private ranges (fc00::/7, fe80::/10, ::ffff: mapped),
strip brackets from URL.hostname, block [::1] and fd00:: addresses
- P2: Guard handleApiKeyBlur against provider change during async
encryption — skip stale write if provider switched while encrypting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address fourth review — main-process key isolation, SearXNG compat
- P1: Replace aiWebSearchDecryptKey IPC with __WEB_SEARCH_KEY__ placeholder
pattern — renderer never sees plaintext keys; main process replaces
placeholder in headers before HTTP request, matching provider key flow
- P1: Search API requests use normal allowlist path (not skipHostCheck),
so SearXNG on localhost/HTTP/private networks works via aiSyncWebSearch;
only url_fetch uses skipHostCheck for arbitrary public HTTPS URLs
- P2: Remove needsApproval from url_fetch — treat as read-only like
sftp_read_file, consistent with observer mode allowlist
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address fifth review — private LAN providers, maxResults default
- P1: Allow private-IP hosts that are explicitly in the provider/search
allowlist (e.g. https://192.168.x.x model providers or SearXNG)
- P2: Remove .default(5) from web_search maxResults schema so the user's
configured maxResults setting is used when the model omits the param
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address sixth review — HTTPS scope, config gate, redirects
- P2: Scope HTTP exception to private/LAN IPs only — remote allowlisted
hosts still require HTTPS to protect API keys in transit
- P2: Gate web_search tool on complete config (API key for providers that
require it, apiHost for SearXNG) to avoid advertising a broken tool
- P2: Add redirect following (up to 5 hops) to aiFetch for url_fetch —
handles 301/302/307 for short links, www canonicalization, etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address seventh review — redirect SSRF, decrypt race, HTTPS-only
- P1: Revalidate each redirect hop against SSRF guards (allowlist check
+ DNS resolution) before following, preventing open-redirect SSRF
- P2: Add sequence counter to API key decryption effect — stale promise
results from a previous provider are discarded on provider switch
- P3: Restrict url_fetch to HTTPS-only URLs, matching the skipHostCheck
policy that already rejects HTTP in the bridge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address eighth review — OS resolver, allowlisted HTTP hosts
- P1: Use dns.lookup (OS resolver) instead of dns.resolve4/6 for private
IP checks — matches what http.request actually connects to, respects
/etc/hosts, mDNS, and other local resolver sources
- P2: Allow HTTP for any explicitly allowlisted host (not just literal
private IPs), so self-hosted SearXNG at http://searxng.lan works
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address ninth review — HTTP scope, blur ordering, decrypt flag
- P1: Narrow HTTP exception to web search apiHost only — AI provider
endpoints remain HTTPS-only to protect credentials in transit
- P2: Add blur sequence counter to prevent out-of-order encryption
results from overwriting newer API key saves
- P2: Reset isDecrypting flag when cancelling decrypt on provider switch,
preventing permanently disabled API key input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address tenth review — DNS pinning, prompt/tool alignment
- P1: Pin validated DNS result to the HTTP request via custom lookup
function, preventing TOCTOU/DNS-rebinding between validation and
actual connection
- P2: Extract isWebSearchReady() helper and use it consistently in
both tool registration and system prompt, so the model isn't told
web search is available when config is incomplete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address eleventh review — single DNS lookup, redirect pinning, CGNAT
- P1: Combine DNS validation and pinning into a single lookup call,
eliminating the TOCTOU window between hasPrivateResolution and pinnedLookup
- P1: Pin DNS for redirect targets too — resolve/validate/pin in one step
before following each redirect hop
- P2: Add 100.64.0.0/10 (CGNAT) to private IP ranges for Tailscale and
similar CGNAT-addressed internal services
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address twelfth review — apiHost validation, sync on enable
- P2: Validate apiHost is a well-formed URL in isWebSearchReady(),
preventing tool exposure when user enters a malformed host
- P2: Add webSearchConfig.enabled to sync effect deps so the main
process gets updated immediately when the toggle changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove DNS-level SSRF checks that break fakedns/proxy environments
DNS resolution validation (dns.lookup + IP pinning) breaks in proxy
environments where fakedns resolves all domains to LAN addresses.
Revert to hostname-level checks only (blocking localhost, 127.0.0.1,
metadata endpoints, etc.) which are sufficient without false positives.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve empty catch block lint warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reject clipboard read requests when terminal is not visible (background
tab), preventing invisible prompts that block remote programs
- Restore terminal focus after user responds to the prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reject concurrent read requests instead of overwriting resolver
- Add autoFocus to Allow button for keyboard accessibility
- Support Escape key to deny the prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a fourth option 'Write + Prompt on Read' that allows clipboard
writes but shows a confirmation dialog before granting read access.
This lets users benefit from remote copy (tmux/vim) while maintaining
control over clipboard reads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only handle clipboard target ('c'); silently ignore unsupported targets
like 'p' (PRIMARY selection) which Electron cannot access, rather than
incorrectly mapping them to the system clipboard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fall back to netcattyBridge.readClipboardText() for clipboard reads
since navigator.clipboard.readText() may be unavailable in Electron
- Chunk String.fromCharCode() calls in 8KB batches to avoid stack
overflow on large clipboard contents
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add osc52Clipboard setting (off/write-only/read-write), default write-only
- Fix UTF-8 decoding: use TextDecoder instead of atob for non-ASCII content
- Support clipboard read requests when mode is read-write
- Add settings UI with Select dropdown and i18n (en + zh-CN)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register an OSC-52 handler on the xterm parser to allow remote programs
(e.g. tmux, vim, neovim) to write to the local system clipboard via
escape sequences. Read requests are ignored for security.
Closes#362
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fs.statSync() is unreliable for Windows named pipes — it returns EBUSY
even when the pipe is fully usable, causing ssh-agent to appear
unavailable. Replaced with net.connect() which is the authoritative
check for named pipe connectivity.
Fixes#360
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The auth dialog's "Continue and Save" button had a dropdown arrow embedded
inside it, but clicking anywhere on the button (including the arrow)
triggered save. Users expected the arrow to offer a no-save option but
couldn't discover it. Refactored to a proper split button: left side
triggers "Continue and Save", right arrow opens a dropdown with
"Continue" (without saving).
Refs #356
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs in snippet package management:
1. Renaming a package with only case changes (e.g. Speedtest → speedtest)
was rejected as duplicate because the case-insensitive check didn't
exclude the package being renamed.
2. Renaming/moving/deleting a package caused its snippets to disappear
because forEach(onSave) called the state updater multiple times with
a stale closure, each call overwriting the previous. Only the last
snippet's update survived. Fixed by adding onBulkSave prop that
passes the entire updated array in one call.
Fixes#357
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The distro detection was using the stored key passphrase instead of the
runtime-resolved passphrase, causing silent failures when users retry
with a manually entered passphrase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: allow settings window as trusted IPC sender
The settings window runs in a separate BrowserWindow with its own
webContents id. validateSender() only checked the main window id,
causing "Unauthorized IPC sender" errors when fetching AI model
lists from the settings page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add validateSender to all remaining AI IPC handlers
15 handlers in aiBridge were missing sender validation, allowing
potential unauthorized IPC calls. Now every netcatty:ai:* handler
consistently validates the sender against trusted windows.
Affected handlers: chat:cancel, agents:discover, resolve-cli,
codex:get-integration, codex:start-login, codex:get-login-session,
codex:cancel-login, codex:logout, mcp:update-sessions,
mcp:set-command-blocklist, mcp:set-command-timeout,
mcp:set-max-iterations, mcp:set-permission-mode, acp:cancel,
acp:cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: scope settings window trust to config-only IPC handlers
Per code review feedback: the previous commit allowed the settings
window to access ALL AI IPC handlers including high-risk ones like
exec, terminal:write, and agent:spawn.
Split into two validators:
- validateSender(): main window only (exec, terminal, agent, stream)
- validateSenderOrSettings(): main + settings (fetch, sync, codex
login, MCP config, agent discovery)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: refresh main window id on recreation and allow settings fetch
Two fixes from code review:
1. Always resolve mainWebContentsId from windowManager instead of
caching it once, so a recreated main window is recognized.
2. Skip static host allowlist for settings window ai:fetch calls,
since the settings UI lets users configure custom provider URLs
that haven't been synced to providerFetchHosts yet. Basic URL
safety (HTTPS-only, no file:// schemes) is still enforced.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: enforce HTTPS/port safety for settings window fetch requests
Per review: previous commit skipped isAllowedFetchUrl entirely for
settings window, which removed SSRF protection. Now settings window
fetches still bypass the static host allowlist (since the user is
configuring new providers) but enforce the same safety rules:
- Remote hosts must use HTTPS
- Localhost must use known ports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sync provider config before fetching models in settings
Instead of bypassing the URL allowlist for settings window fetches
(which weakens SSRF protection), have ModelSelector sync the current
provider's baseURL to the backend allowlist before fetching models.
This keeps the full URL safety checks intact while allowing settings
to test custom provider endpoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use dedicated allowlist handler instead of syncing providers
Replace the approach of calling aiSyncProviders (which overwrites
the shared providerConfigs) with a new lightweight IPC handler
netcatty:ai:allowlist:add-host that only adds a host to the fetch
allowlist without affecting provider configs or API key resolution.
This preserves the SSRF protection while allowing settings to test
custom provider URLs that haven't been synced from the main window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: auto-expire temporary allowlist entries after 30 seconds
Temporary hosts added via allowlist:add-host now auto-remove after
30s to prevent permanently expanding the SSRF boundary. Built-in
ports and hosts re-added by provider sync are preserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent temp allowlist cleanup from removing synced providers
The setTimeout cleanup now checks whether the host/port belongs to
a currently synced provider config before removing it. This prevents
the scenario where a user saves a provider within the 30s TTL window
and then loses access when the timer fires.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve temp allowlist entries across provider sync rebuilds
rebuildProviderFetchHosts() clears and rebuilds the allowlist from
providerConfigs, which would wipe temporary entries added by
allowlist:add-host. Now re-adds active temp entries after rebuild
to prevent race conditions between settings model listing and
provider sync from the main window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add settings cloud sync support (closes#347)
Expand SyncPayload.settings to include all syncable user preferences
(theme, appearance, terminal, keyboard, editor, SFTP). Add
collectSyncableSettings/applySyncableSettings helpers in syncPayload.ts,
wire rehydrateAllFromStorage through App.tsx and SettingsPage.tsx so
in-memory React state updates after a cloud download.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: include settings in auto-sync uploads and sync empty customCSS
P1: useAutoSync.buildPayload now includes collectSyncableSettings()
so settings are uploaded alongside vault data.
P2: customCSS uses != null check instead of truthy, so clearing CSS
on one device is properly synced to others.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: include settings in auto-sync change detection hash
Settings-only changes (theme, terminal options, etc.) now trigger
auto-sync uploads. The data hash comparison includes the settings
snapshot alongside vault data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: trigger auto-sync on settings changes and sync custom terminal themes
P1: Added settingsVersion (derived from all synced settings via useMemo)
to useAutoSync debounce effect dependencies. Settings-only changes now
trigger auto-sync uploads.
P2: Custom terminal themes (STORAGE_KEY_CUSTOM_THEMES) are now included
in the sync payload so custom themes are available on other devices.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reload custom theme store after sync, include in change detection
P1: customThemeStore.loadFromStorage() is now called in
rehydrateAllFromStorage so synced custom themes are immediately
reflected in the live theme store.
P2a: customThemes added to settingsVersion dependencies so custom
theme edits trigger auto-sync.
P2b: Empty custom themes array is now preserved in sync payload
to properly propagate theme deletion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: notify subscribers after custom theme store reload
loadFromStorage now calls notify() to trigger useSyncExternalStore
subscribers, so synced custom terminal themes are immediately
visible in all windows after apply.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add auto-update toggle setting (closes#346)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: re-check auto-update toggle when startup timer fires
Address review feedback: the startup check effect now re-reads the
toggle from localStorage when the delayed timer fires, so toggling
off after launch cancels the pending check. Also avoids setting
hasCheckedOnStartupRef when disabled, allowing re-enable to trigger
a check without restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback on auto-update toggle
P1: When autoDownload=false, onUpdateAvailable no longer transitions
to 'downloading' status. Instead keeps autoDownloadStatus idle so
the manual download link surfaces correctly.
P2: Added reactive autoUpdateEnabled state (synced via storage event)
as a dependency to the startup check effect. Re-enabling the toggle
mid-session now re-triggers the deferred startup check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address P1/P2 review feedback on auto-update toggle
P1: Main process update-available handler now checks updater.autoDownload
before setting _lastStatus to 'downloading'. When autoDownload=false,
status stays 'idle' so late-opened windows don't hydrate to a stuck
0% download state.
P2: useUpdateCheck now accepts autoUpdateEnabled as a prop from the
caller instead of relying solely on storage events (which don't fire
in the same window). SettingsPage passes settings.autoUpdateEnabled
directly, so toggling in the current window takes effect immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve update-available info for late-opening windows
When autoDownload is off, use status 'available' (instead of 'idle')
in the main process snapshot so late-opening windows can hydrate
version info. The renderer maps 'available' to hasUpdate=true while
keeping autoDownloadStatus='idle' for the manual download path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: re-schedule auto-check on re-enable and guard startup timer
- IPC handler now calls startAutoCheck(2000) when re-enabling so the
user gets automatic checks without restarting the app.
- startAutoCheck timer checks updater.autoDownload at fire time, so
if the renderer disables auto-update via IPC before the 5s startup
timer fires, the check is skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: deduplicate auto-check scheduling and clear error on fallback success
P1: startAutoCheck now cancels any existing timer before scheduling
a new one, preventing duplicate concurrent checks from multiple
windows or re-enable toggles.
P2: checkNow fallback now clears manualCheckStatus='error' when
electron-updater successfully finds an update (res.available=true),
so the UI shows 'available' instead of a stale error state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only reschedule on actual re-enable and hydrate cache before toggle check
P2: Track previous autoDownload state in IPC handler so startAutoCheck
is only called on actual false→true transitions, not on every window
mount that syncs the current value.
P3: Move cache hydration (STORAGE_KEY_UPDATE_LATEST_RELEASE) before
the auto-update toggle check so cached update info is always visible
even when automatic updates are disabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: persist auto-update preference in main process across restarts
Read/write auto-update preference to a JSON file in userData so the
main process honors it on next launch without waiting for renderer IPC.
getAutoUpdater() now initializes autoDownload from the persisted value.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: suppress cached update toast when disabled and update IPC types
P2: Cache hydration now gates hasUpdate on autoUpdateEnabled so the
App.tsx toast doesn't fire when automatic updates are disabled.
P3: Updated global.d.ts to include 'available' in getUpdateStatus
status union and 'checking' in checkForUpdate return type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve dismissed releases, show cached updates in Settings, guard concurrent checks
P2a: Updater fallback now checks STORAGE_KEY_UPDATE_DISMISSED_VERSION
before re-surfacing a release found by electron-updater.
P2b: Cache hydration always sets hasUpdate truthfully so Settings
shows the available update. Toast suppression for disabled auto-update
moved to App.tsx (reads localStorage directly).
P3: Re-enable IPC handler checks _isChecking before scheduling
startAutoCheck to prevent concurrent electron-updater calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use localStorageAdapter for lint compliance, skip IPC on initial mount
P1: Replace direct localStorage access with localStorageAdapter in
App.tsx toast guard to fix no-restricted-globals lint error.
P2: Skip setAutoUpdate IPC on initial mount to prevent overwriting
the main-process preference file when renderer localStorage has been
cleared (where the default would be true).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: hydrate auto-update state from main-process preference on mount
Add getAutoUpdate IPC handler so the renderer can query the persisted
preference from auto-update-pref.json. On mount, useSettingsState
reconciles localStorage with the main-process truth, preventing the
toggle from showing 'enabled' when the user had previously disabled
it and localStorage was cleared.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When rebuilding SDK messages from conversation history, tool-result
messages had toolName hardcoded to an empty string. This works for
OpenAI/Claude APIs but Gemini requires functionResponse.name to be
non-empty, causing AI_APICallError on every follow-up message.
Now looks up the tool name from the matching assistant tool call
via toolCallId.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The MCP server runs as a standalone Node process (not Electron), so it
cannot access modules inside app.asar. Add missing transitive deps
(zod-to-json-schema, ajv, ajv-formats, fast-deep-equal, fast-uri,
json-schema-traverse) to asarUnpack so they are available on disk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ubuntu-latest (24.04) links native modules against glibc 2.39 which can
cause dlopen failures on some distros. Pin to 22.04 (glibc 2.35) for
wider compatibility across Linux distributions.
Related: #264
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The beta version had native module loading issues on Arch Linux AppImage
builds (ERR_DLOPEN_FAILED). The stable release uses an improved module
loading strategy with prebuild support for macOS/Windows and better
build-from-source fallback for Linux.
Related: #264
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, Netcatty checked if the OpenSSH Authentication Agent Windows
service was running via `sc query ssh-agent`. This broke compatibility
with third-party SSH agents (Bitwarden, 1Password, gpg-agent) that
provide the same named pipe without running the system service.
Now we probe `\\.\pipe\openssh-ssh-agent` directly with fs.statSync,
which works regardless of which agent provides the pipe.
Fixes#343
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- streamRequest now resolves on headers arrival with statusCode/statusText
so the renderer constructs Response with the real HTTP status (e.g. 401)
instead of hardcoded 200
- Provider fetch URL allowlist is now dynamically rebuilt from configured
provider baseURLs on sync, supporting custom provider endpoints
- Localhost port allowlist properly resets on provider sync (no stale ports)
- PTY marker detection requires line-boundary match to avoid false positives
- Clarify terminal_send_input vs terminal_execute usage in tool descriptions
and system prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Security: API keys no longer transit IPC as plaintext; renderer sends
providerId and main process decrypts via safeStorage. Add ReDoS
protection for user-supplied blocklist regex. Sanitize error messages
to strip file paths and sensitive URLs before displaying in chat.
- Bug fixes: approval timeout now notifies user in chat instead of
silently aborting. statusText cleared consistently across all 7 code
paths. useAIState persistence race condition fixed with mountedRef
guard, storage sync validated with type checks.
- Accessibility: InlineApprovalCard uses role="alertdialog" with focus
on approve button. ChatInput menus have proper ARIA roles, labels,
and aria-expanded. ThinkingBlock toggle has aria-expanded/controls.
Model selector submenu supports keyboard navigation.
- UX: switching agents preserves old session and creates new one.
Approval card buttons disabled immediately after click.
- Performance: text-delta streaming batched via requestAnimationFrame.
- Refactor: extract useAIChatStreaming, useToolApproval, and
useConversationExport hooks from AIChatSidePanel (1514 → 751 lines).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Waiting for response from agent..." status text was persisting
after tool completion because only onTextDelta cleared statusText.
When an agent went directly from tool-result to a new tool-call
without emitting text, the stale statusText remained visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Security: sanitize command input in resolveCliFromPath, add host allowlist
to streaming endpoint, enforce permission model in MCP server tools, add
safety check to terminal:write IPC, fix broken blocklist regex, remove
renderer-controlled allowedHosts parameter.
Correctness: use sessionsRef for latest state in handleSend, merge
add+update to avoid race condition in streaming, mark assistant message
completed after tool-result, return JSON-RPC error for unhandled ACP
permission requests, add finished guard in ptyExec.
Performance: custom React.memo comparator for ChatMessageList, fix doom
loop detection threshold.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All external agents now use ACP protocol exclusively. The Claude Agent
SDK flow was fully implemented but never wired into the UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Security:
- Apply checkCommandSafety() in aiExec IPC handler to enforce command blocklist
- Add critical path validation in handleSftpRemove (normalize + blocklist)
- Change rm -rf to rm -r so permission errors surface
Stream lifecycle:
- Abort all active streams on component unmount (abortControllersRef cleanup)
- Wrap stream reader in try/finally with releaseLock() to prevent leaks
- Use refs for inputValue/images in handleSend to stabilize callback identity
State persistence:
- Clear debounce timer before synchronous persist in destructive operations
(clearSessionMessages, deleteSession, deleteSessionsByTarget)
- Add 10MB max buffer guard in ACP client and MCP server NDJSON parsing
i18n:
- Replace hardcoded English strings with t() calls in InlineApprovalCard,
PermissionDialog, ConversationExport, ThinkingBlock, AgentSelector
- Add 23 new i18n keys to en.ts and zh-CN.ts
Misc:
- Remove debug console.log statements in mcpServerBridge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The finally block in handleApprovalResponse still used scope key (sk)
instead of session ID (sid) for setStreamingForScope and abort controller
cleanup, causing the streaming indicator to not clear after approval flow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When switching agents or creating a new chat within the same scope,
the stop button stayed red because streaming was keyed by scopeKey
(which doesn't change). Now streaming and abort controllers are keyed
by sessionId, so switching to a different session correctly shows the
idle state while the old session continues streaming in background.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reduce ACP stall detection from 15s to 3s for faster feedback
- Add statusText field to ChatMessage for transient status display
- Render status text with thinking-shimmer CSS animation
- Clear statusText when real content arrives (onTextDelta)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add stall detection in ACP stream loop: if no chunk received for 15s,
send a "Waiting for response..." status event to the chat panel
- Add onStatus callback to AcpAgentCallbacks, render as italic text
- Forward status events from main process to renderer via acp:event IPC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When no model is stored, the default was bare "gpt-5.4" which Codex
rejects. Now defaults to "gpt-5.4/xhigh" (highest thinking level)
for models that require a thinking level suffix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix stale closure: add updateLastMessage to handleApprovalResponse deps
- Use random heredoc delimiter to prevent content corruption when file
contains the literal delimiter string
- Remove dead ensureClaudeConfigDir function from claudeHelpers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add errorInfo field to ChatMessage with type classification
(network/auth/timeout/provider/agent/unknown) and retryable flag
- Create errorClassifier.ts to map raw error strings into user-friendly
structured messages with actionable hints
- Replace inline "**Error:**" text appending with dedicated error messages
rendered as styled error cards with AlertCircle icon
- Ensure streaming indicator is cleared immediately on error in ACP and
external agent flows (not just in finally block)
- Mark previous message executionStatus as failed only when it was running
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Catty Agent tools call the bridge directly (not via MCP Server), so the
MCP-level observer enforcement doesn't apply. Add explicit isObserver
guards in all write tool execute functions (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) to return
an error when Observer mode is active.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Show a clickable chip next to the model selector in ChatInput footer
that lets users quickly toggle between Observer/Confirm/Auto permission
modes. Only visible when Catty Agent is selected (ACP agents handle
their own tool approval flow).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the full-screen PermissionDialog modal with inline InlineApprovalCard
rendered within chat messages. Implement the multi-turn approval flow so that
clicking Approve actually resumes the agent loop via a new streamText call with
proper SDK tool-approval-response messages.
Fix toolCallId extraction (toolCall.toolCallId, not chunk.toolCallId) and use
correct SDK field name (input, not args) for ToolCallPart content parts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use Vercel AI SDK's native `needsApproval` on write tools (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) when permission
mode is "confirm". When the SDK emits a `tool-approval-request` stream event,
show the existing PermissionDialog component for user to approve/reject.
- tools.ts: replace manual checkToolPermission() calls with `needsApproval`
property on write tools; keep blocklist checks in execute()
- AIChatSidePanel: handle `tool-approval-request` chunk type, show
PermissionDialog via Promise-based pause, resolve on user action
- Add i18n key for tool denied message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The StorageEvent handler was missing cases for STORAGE_KEY_AI_HOST_PERMISSIONS
and STORAGE_KEY_AI_AGENT_MODEL_MAP, so changes made in the settings window
were not picked up by the main window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Safety enforcement:
- Command Timeout: use user setting in MCP Server (execViaPty/execViaChannel)
and Catty Agent path (aiBridge execViaPtyForCatty/execViaChannel fallback)
- Max Iterations: read user setting for Catty (stepCountIs) and ACP (via IPC)
- Permission Mode: observer mode hard-blocks write ops in MCP Server dispatch;
Catty SDK tools wired to checkToolPermission(); all synced via IPC on change
Command Blocklist UI:
- Add editable regex pattern list in Settings AI Safety section
- Reset to defaults button, add/remove patterns
- IPC sync to MCP Server on change and on mount
Provider UX:
- ModelSelector rewritten as combobox: type-to-filter with suggestions dropdown
- All providers use unified ModelSelector (modelsEndpoint optional)
- API key passed as auth header for model fetching (Bearer / x-api-key)
- Skip model fetch when no API key (except Ollama)
- Provider toggle is now mutually exclusive (activating one disables others)
- New providers default to disabled (switch off)
- Custom provider supports editable display name
- No-provider error: friendly message shown in chat
i18n:
- Full i18n coverage for Settings AI tab (~55 keys)
- Full i18n for AI chat panel (placeholder, empty state, time, sessions)
- en.ts and zh-CN.ts locale files updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace single selectedAgentModel state with per-agent agentModelMap
persisted to localStorage, so each agent remembers its last selected model
- Default to first preset when no prior selection exists
- Fix Claude model presets: use 'default' instead of 'opus' to match
what claude-code-acp actually exposes (default=Opus 4.6)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Restore currentAgentId from active session when switching scopes
- Add i18n for "Agent Settings" label in agent selector
- Add agent icons to settings page default agent dropdown
- Replace catty.svg with new icon, use Settings icon for manage button
- Fix lint: missing useCallback deps, no-restricted-globals, useMemo deps
- Guard webContents.send against disposed render frames in windowManager
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enlarge side panel tab buttons for better clickability, persist panel
width to localStorage, switch AI tab icon to MessageSquare, and add
dedicated catty.svg with violet badge styling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove chatSessionId from MCP server config env vars so it doesn't
affect the fingerprint calculation. Previously, different chat sessions
in the same workspace produced different fingerprints, causing the ACP
provider to be recreated when switching back to a previous session,
losing all conversation history.
Now the fingerprint only depends on the workspace scope (host session
IDs and MCP port), so providers are correctly reused when returning
to a previous chat session. Each chat session still has its own
provider instance (keyed by chatSessionId in the acpProviders Map).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix workspace host discovery: use `activeWorkspace.root` instead of
non-existent `.tree` property, which caused `collectSessionIds` to
always return empty for workspaces
- Isolate AI chat state per-scope (tab/workspace): activeSessionId,
isStreaming, abortController, and inputValue are now keyed by
`${scopeType}:${scopeTargetId}` so different tabs don't interfere
- Add per-chatSession MCP scope metadata to prevent host list mixing
across workspaces, with chatSessionId passed through the full IPC chain
- Store hostname/username in SSH session objects as fallback for MCP
host info when renderer metadata is unavailable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove claude-agent-sdk streaming path (unused, causes confusion)
- Claude Agent now uses ACP path like other external agents
- ACP path already has aiMcpUpdateSessions for scope isolation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The MCP server was exposing ALL terminal sessions to AI agents regardless
of which workspace the agent belonged to. Fixed by:
- Track scoped session IDs when updateSessionMetadata is called
- buildMcpServerConfig now auto-uses current scoped IDs when no explicit
scope is provided, setting NETCATTY_MCP_SESSION_IDS env var
- handleGetContext falls back to sessionMetadata keys when no explicit
scopedSessionIds param is passed
This ensures agents only see hosts within their workspace/terminal scope.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AI Settings:
- Remove External Agents section, add dedicated Codex/Claude Code sections
with auto-detection and manual path override
- Add OpenRouter model list auto-fetch with searchable dropdown
- Remove standalone Default Model section, integrate into provider cards
- Provider toggle now acts as mutual-exclusive active selector
- Add cross-window state sync via storage events
- Add ErrorBoundary around AI tab for graceful error handling
Catty Agent fixes:
- Fix streaming: use .chat() instead of default Responses API, use
getReader() pattern instead of for-await, handle text-delta/text chunk
types correctly per SDK v6
- Fix API key: decrypt encrypted key before passing to SDK
- Fix terminal_execute: use PTY stream (visible in terminal) with MCP
markers instead of invisible SSH exec channel
- Fix multi-turn: only pass user/assistant text history, skip tool
messages to avoid SDK schema validation errors
- Fix tool result display: create new assistant message after tool
results so follow-up text renders correctly
Other:
- Add netcatty:ai:resolve-cli IPC handler for CLI path validation
- Remove Gemini from agent discovery (only Codex + Claude Code)
- Fix lint errors across ChatInput, TerminalLayer, SettingsAITab
- Strip MCP markers from tool execution stdout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add image paste/drop support with base64 encoding and chat display
- Add + button popover (Files, Image, Mention Host) with @ auto-complete
- Add model selector with Codex thinking level sub-menus (GPT 5.4, Codex 5.x, o3/o4-mini)
- Switch Claude Code to ACP protocol; remove CLAUDE_CONFIG_DIR isolation
- Filter PTY exec markers in preload data pipe (precise regex, preserves command echo)
- Increase PTY exec timeout to 5min with Ctrl+C cancellation on abort
- Fix tool call loading spinner (only animate during active streaming)
- Reset active session on terminal/workspace switch
- Add AI sparkle button in top bar to toggle AI panel
- Display user-attached images in chat message list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create netcatty-remote-hosts MCP server (TCP bridge + stdio child process)
exposing terminal_execute, sftp_*, get_environment tools to ACP agents
- Execute commands via PTY stream with self-erasing markers for terminal
visibility; disable pagers automatically; 60s timeout fallback
- Inject MCP server into both Codex (ACP) and Claude Code (ACP) sessions
- Add model selector popover with hardcoded presets for Claude (Opus/Sonnet/
Haiku) and Codex (GPT 5.4, Codex 5.3/5.2/5.1, o3/o4-mini) with thinking
level sub-menus
- Fix multi-step tool call message flow (mutable flag instead of stale closure)
- Remove CLAUDE_CONFIG_DIR isolation to fix Claude auth; switch Claude to ACP
- Add startup cleanup for orphaned AI sessions
- Guard collectSessionIds against undefined workspace tree
- Remove permission mode chip from chat input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Switching agent deactivates current session, next message creates a new one
- Filter history sessions by both scopeType and targetId
- Restore agent selector when resuming a historical session
- Auto-cleanup AI sessions when terminal/workspace instances are destroyed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Integrate Claude Agent SDK for direct streaming chat, add Codex login/logout
flow with OAuth support in settings, improve AI chat panel UI and agent
discovery, and update build config for new dependencies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add auto-discovery of CLI agents (Claude Code, Codex, Gemini) from system PATH
- Integrate ACP (Agent Client Protocol) for real-time streaming with codex-acp
- Bundle @zed-industries/codex-acp binary for reliable agent spawning
- Add ThinkingBlock component with shimmer animation and auto-collapse
- Refactor chat UI: no avatars, bordered user bubbles, plain assistant text
- Support {prompt} placeholder in agent args for flexible invocation
- Add persistent ACP sessions with proper cleanup on app exit
- Detect auth errors and show actionable messages to users
- Fallback to raw process spawn for agents without ACP support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace native title attributes with Radix UI Tooltip components for
a consistent, styled tooltip experience across both toolbars.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix onSelectionChange listener leak in Terminal.tsx (missing dispose on cleanup)
- Debounce window resize handler in TopTabs.tsx to prevent IPC storm
- Use .once() for SSH/SFTP/PortForward connection lifecycle events (ready/error/timeout/close)
to prevent listener accumulation across sessions
- Clean up sessionEncodings/sessionDecoders maps in all error paths in sshBridge
- Use .once() for execCommand() connection events (creates new conn per call)
- Remove duplicate requestAnimationFrame in useSftpPaneVirtualList
- Capture and dispose OSC 7 parser handler in createXTermRuntime
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a position toggle button next to the close button in the side panel
header. The position preference is persisted in localStorage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enable xterm.js customGlyphs option so box-drawing and block characters
are rendered by canvas instead of font glyphs, eliminating visible gaps.
Closes#331
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add httpsAgent with rejectUnauthorized:false in WebDAVAdapter.createClient()
so the fallback (non-bridge) path also respects the allowInsecure option
- Use explicit ternary for allowInsecure config serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow users to bypass TLS certificate verification for WebDAV endpoints
using self-signed certificates, which is common for LAN NAS devices
(Synology, FNAS, Unraid, etc.).
Closes#332
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: move Scripts and Theme from toolbar popups to side panel sub-tabs
Migrate Scripts (snippet library) and Theme customization from toolbar
popover/modal dialogs into the left side panel alongside SFTP. The panel
header now shows three tab buttons (SFTP / Scripts / Theme) so users can
switch between sub-panels without losing SFTP connections.
- Add ScriptsSidePanel with package hierarchy, breadcrumb nav and search
- Add ThemeSidePanel adapted from ThemeCustomizeModal (no preview pane)
- Generalize TerminalLayer state from sftpOpenTabs to sidePanelOpenTabs
- Simplify TerminalToolbar by removing inline popover and modal rendering
- Clicking the already-active tab button is a no-op; only X closes panel
- Theme/font changes apply in real-time to the actual terminal behind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review findings for side panel migration
- Clean up sftpInitialLocationForTab on panel close
- Remove unused handleCloseSidePanel from deps array
- Re-focus terminal after snippet execution from side panel
- Use props directly in ThemeSidePanel instead of mirrored local state
- Use ?? instead of || for falsy-safe theme/font/size defaults
- Extract isFocusedHostLocal into memoized value
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- Add SFTP side panel with workspace-level connection caching for instant switching between terminal endpoints
- Responsive toolbar with overflow menu that collapses action buttons when panel is narrow, prioritizing breadcrumb path display
- Silent terminal CWD detection via separate SSH exec channel (no visible commands in terminal)
- Extract SftpTransferQueue as reusable component with i18n support
- Remove passphrase from port forwarding credentials (decrypted at load time)
- Add compressed upload support to uploadEntriesDirect
- Fix various eslint warnings and code quality issues
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Redesign tabs from rounded rectangle + accent border to flat-bottom
Windows Terminal style with top accent line indicator
- Show OS/distro icons with brand background colors in session tabs
- Add OS-specific icons (macOS/Windows/Linux) for local terminal tabs
with auto-detection via navigator.userAgent
- Add SVG assets for macOS, Windows, and Linux logos
- Give Vaults tab a distinct style (rounded, semi-transparent bg,
no accent line) to differentiate from session tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When reopening the SFTP modal via drag-and-drop, the session effect's
initialization IIFE runs async (ensureSftp + listSftp ~0.5s). During
this window, dependency changes (e.g. loadFiles recreation from
files.length change by the layout effect clearing stale cache) can
re-trigger the session effect. Since initializedRef is already true,
the effect falls through to loadFiles(currentPath) with the OLD path.
If this loadFiles resolves before the IIFE, loading transitions to
false prematurely, causing the auto-upload to snapshot the stale
currentPathRef and upload to the wrong directory.
Add an initializingRef flag that is set when the initialization IIFE
starts and cleared in its finally block. The fallthrough loadFiles
call is skipped while initializingRef is true, ensuring only the
IIFE's completion triggers the loading transition that the auto-upload
effect relies on.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On macOS, the system tray keeps the app process alive after all windows
are closed, preventing quitAndInstall from completing the restart.
Clean up the tray and its panel window before calling quitAndInstall so
the app can exit cleanly and the installer can proceed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve conflict in useAutoSync.ts by integrating getEffectiveKnownHosts
into the refactored getSyncSnapshot function, avoiding duplication in
getDataHash which now delegates to getSyncSnapshot.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(sync): include snippetPackages in cloud sync payload (#315)
Snippet packages (the grouping tree for code snippets) were not included
in the cloud sync payload, causing them to be lost when syncing across
devices. This adds snippetPackages as an optional field following the
same backward-compatible pattern used by knownHosts and
portForwardingRules: old payloads that lack the field leave local
packages untouched.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make snippetPackages optional in SyncableVaultData for consistency
Aligns with the pattern used by knownHosts — optional in both
SyncableVaultData and SyncPayload so that legacy data without the field
is handled gracefully. Also updates the SyncPayloadImporters docstring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Handle { checking: true } response from bridge.checkForUpdate()
separately instead of treating it as "no update" — an in-flight
check will resolve via IPC events
- Restore dismissUpdate() in "View in Settings" toast onClick so
unsupported-platform users can suppress the notification; on
supported platforms the Settings window picks up download state
via IPC events independently
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Set dismissedAutoDownloadRef when hydration skips a dismissed version
so subsequent IPC events (progress/downloaded) are also suppressed
- When GitHub API fails but electron-updater fallback finds no update,
clear manualCheckStatus from 'error' to 'up-to-date' instead of
leaving Settings stuck in error state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When checkNow's GitHub API call fails (blocked/rate-limited), still
trigger electron-updater's checkForUpdate as a fallback. This restores
the update path for environments where api.github.com is unreachable
but the updater feed is still accessible.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove STORAGE_KEY_UPDATE_LAST_CHECK write from onUpdateNotAvailable
handler — it would prevent the GitHub API fallback from running on app
restart, hiding releases that exist on GitHub but aren't yet in the
electron-updater feed. Let performCheck write the timestamp instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove dismissUpdate() from "View in Settings" toast onClick — writing
to STORAGE_KEY_UPDATE_DISMISSED_VERSION would cause the Settings window
hydration to skip download state, making it appear idle
- Remove unused dismissUpdate import from App.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Don't cancel startup GitHub API fallback when electron-updater says
not-available — the GitHub release may exist before updater feed
assets are published, and the fallback provides manual download link
- Rescheduled fallback now re-queries getUpdateStatus to avoid duplicate
notifications on very slow networks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace autoDownloadStatus==='idle' guard in progress/downloaded/error
callbacks with a dedicated dismissedAutoDownloadRef to distinguish
"dismissed version" from "not hydrated yet" in late-opening windows
- Don't clear hasUpdate on update-not-available — GitHub release may
exist even when electron-updater feed says no compatible update,
preserving the manual download fallback path
- Reset dismissedAutoDownloadRef on manual retry via checkNow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- When the startup fallback sees the main process check still in flight,
reschedule after 5s instead of permanently skipping — handles the case
where the auto-check fails silently (check-phase errors not broadcast)
- onUpdateNotAvailable: clear hasUpdate and manualCheckStatus to remove
stale "update available" state from earlier GitHub API checks, since
the updater feed is authoritative on supported platforms
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- checkNow: check dismissed version before marking status as 'available'
to prevent re-downloading a release the user explicitly skipped
- startAutoCheck: verify updater exists before setting _isChecking flag
to avoid permanent stuck state when electron-updater fails to load
- Clear _isChecking in all catch paths to prevent stuck state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _isChecking flag in autoUpdateBridge to track whether
checkForUpdates is in flight; return sentinel when manual check
arrives during an active auto-check instead of starting a concurrent
call that electron-updater would reject
- Include isChecking in getUpdateStatus snapshot so the renderer can
query it before starting the GitHub API fallback
- Startup fallback now checks getUpdateStatus().isChecking to skip
when electron-updater is still checking on slow networks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Store startAutoCheck timer ID so it can be cancelled; cancel it when
the renderer triggers a manual checkForUpdate to avoid concurrent
electron-updater calls that produce false errors
- Record lastCheckedAt and STORAGE_KEY_UPDATE_LAST_CHECK when
update-not-available fires so the throttle works on the common
no-update path and "Last checked" UI shows correctly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Broadcast 'update-not-available' from electron-updater to renderer so
the startup GitHub API check is cancelled when no update exists
- Cancel pending startup timeout in checkNow() to prevent racing with
electron-updater's startAutoCheck (concurrent calls cause false errors)
- Add onUpdateNotAvailable bridge event (preload + global.d.ts types)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- getUpdateStatus hydration: skip restoring download state for dismissed
versions so late-opening windows don't show dismissed release UI
- performCheck: only advance lastCheck timestamp and cache release data
on successful checks — failed checks no longer suppress re-checks for
an hour while leaving stale cached release visible
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- onUpdateAvailable: skip autoDownloadStatus→'downloading' transition when
version is dismissed, preventing download progress/ready toasts
- onUpdateAvailable: cancel pending startup GitHub API check timeout to
eliminate race where electron-updater is still checking at 8s
- onUpdateDownloadProgress/Downloaded/Error: suppress state transitions
when autoDownloadStatus is 'idle' (dismissed version background download)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Return idle instead of up-to-date for dev/invalid builds in
checkNow to avoid false positive status
- Replace stale cached release in getUpdateStatus hydration when
the snapshot reports a different version
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use semantic version comparison for cached release hydration to
avoid false positives when running a newer build than latest release
- Restore dismissUpdate() in startup toast so unsupported-platform
users can silence repeated notifications
- Remove dismissed-version check from ready-to-install toast since
dismissing availability should not block the install prompt
- Show manual download link in Settings on check errors too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Persist latestRelease to localStorage so windows opened after the
initial check can hydrate release info without re-fetching
- Remove dismissUpdate() from "View in Settings" toast click — the
dismissed version key was preventing the later install-ready toast
- Hydrate cached release data when startup check is throttled so
Settings windows show the already-found update
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Don't treat unsupported auto-update platforms as download errors;
keep autoDownloadStatus at idle so manual download link shows
- Remove auto-check on SettingsApplicationTab mount to avoid
implicitly triggering downloads when opening Settings
- Update Application tab badge to reflect download/ready state
instead of always showing "Download Now"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace stale latestRelease when electron-updater reports a different
version than the cached GitHub API result
- Surface checkForUpdate() failures by setting autoDownloadStatus to
error instead of silently dropping them
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Check dismissed version before showing ready-to-install toast so
users who skipped a release are not re-prompted after restart
- Reset _lastStatus on update-not-available so late-opening windows
don't hydrate stale error/ready state from a previous check cycle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The autoDownloadStatus read from updateState was captured at render
time, so after checkNow() resolves it still shows 'idle' even when
electron-updater has already started downloading. Remove the
openReleasePage() call entirely — checkNow() already triggers
electron-updater on supported platforms, and SettingsSystemTab shows
a manual download link on unsupported platforms.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove hasUpdate gate from ready-to-install toast so dismissing
availability notification doesn't prevent restart prompt
- Only open releases page on platforms without auto-download
- Increase startup check delay to 8s to let electron-updater fire first
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Skip renderer's GitHub API startup check if electron-updater's
auto-download has already started, preventing duplicate toast
notifications for the same release
- Set hasUpdate in onUpdateAvailable IPC handler, checking dismissed
version so that dismissed releases don't trigger the persistent
"restart now" toast after auto-download completes
- Guard "ready to install" toast with hasUpdate check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix autoDownloadStatusRef stale read during checkNow retry: eagerly
sync the ref when resetting error->idle so checkForUpdate() fires
- Refactor SettingsApplicationTab to accept update props instead of
creating its own useUpdateCheck instance, preventing duplicate checks
and inconsistent state between Application and System tabs
- Show startup-detected updates (hasUpdate) in System tab, not only
manualCheckStatus=available, so Linux/unsupported platforms see the
update and manual download button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add getUpdateStatus IPC handler so windows opened after download started
can immediately reflect the current state instead of showing stale 'idle'
- Track _lastStatus in main process across all updater events
- Hydrate autoDownloadStatus on useUpdateCheck mount via getUpdateStatus()
- Fix toast race: use ref to track previous autoDownloadStatus so ready/error
toasts only fire on actual status transitions, not when unrelated callback
references change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Handle clock skew (timestamp in the future) by treating negative
diff as "just now" instead of displaying negative time values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unused getSenderWindow() from autoUpdateBridge (replaced by broadcastToAllWindows)
- Fix .gitignore: /CLAUDE.md instead of CLAUDE.md to only match root
- Merge duplicate [Unreleased] sections in CHANGELOG.md
- Correct checkNow description: uses GitHub API, then triggers electron-updater async
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add _isDownloading flag to track whether a download is in progress.
Set true on update-available (autoDownload=true starts download immediately),
reset on update-downloaded or error.
In the error handler, only broadcast netcatty:update:error when _isDownloading
is true — check-phase errors (e.g. startup network failures) are logged to
console only and do not set autoDownloadStatus in the renderer, preventing
false 'download failed' states when no download was ever attempted.
performCheck returns a non-null UpdateCheckResult with error populated
on GitHub API/network failures. Extend the status derivation to treat
result.error as an error state instead of falling through to up-to-date.
P1: change checkNow return type from Promise<null> to Promise<UpdateCheckResult | null>
and return actual result so callers can read hasUpdate/latestRelease.
P2: reset autoDownloadStatus from 'error' to 'idle' when user triggers manual
check, enabling a retry path; also show Check for Updates button in error state.
- Fix 1: when manual check finds update and electron-updater hasn't started
downloading yet (autoDownloadStatus=idle), fire-and-forget checkForUpdate()
to kick off the auto-download pipeline without blocking the UI
- Fix 2: manualCheckStatus='up-to-date' now auto-resets to 'idle' after 5s
so the badge doesn't stay stale until the next check; any new check cancels
the pending timer first
- Fix 3: SettingsSystemTab shows "last checked: X min ago" below the update
section using lastCheckedAt from updateState; new i18n keys added for both
zh-CN and en locales (lastCheckedJustNow, lastCheckedMinutesAgo,
lastCheckedHoursAgo, lastCheckedPrefix)
Internal: add autoDownloadStatusRef and manualCheckResetTimeoutRef to
useUpdateCheck for reliable cross-closure state access and timer lifecycle.
* fix(sftp): update currentPath immediately on navigation to prevent stale upload target
When navigating directories without a cache hit, currentPath was only
updated after the async file listing completed. If a drag-and-drop upload
occurred during or shortly after this window, getActivePane would return
the old currentPath, causing files to upload to the previous directory.
Now currentPath is updated immediately when loading begins, ensuring
upload operations always target the correct directory.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): revert currentPath to previous value when navigation fails
Address review feedback: if the directory listing throws a non-session
error, restore currentPath to its previous value so later operations
(e.g. uploads) don't target a path that was never successfully loaded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): clear files when entering loading state to prevent stale interactions
Address P1 review: the loading overlay is pointer-events-none, so users
could still interact with old files during navigation. Since currentPath
is now updated immediately, actions like delete/rename would resolve
against the new path but display old files. Clear files and selection
when loading begins to eliminate this inconsistency.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): restore previous files when reverting path on navigation error
Address P2 review: since files are now cleared when loading begins,
a failed navigation would leave the pane with the old path but an
empty file list. Save and restore the previous files alongside the
previous path in the error handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): restore selected files when reverting on navigation error
Address P2 review: save and restore selectedFiles alongside path and
files in the error handler so users don't lose their selection when
a navigation attempt fails.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): restore tab state when navigation is superseded by another request
Address P1 review: navSeqRef is tracked per-side not per-tab, so a
navigation from a different tab on the same side can invalidate this
request. When the sequence check causes an early return, restore this
tab's previous path, files, and selection instead of leaving it with
cleared files and a stale loading state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): avoid overwriting newer navigation state when superseded
When a navigation request is superseded by a newer one on the same tab
(e.g., fast A→B→C), the completing request should not blindly restore
its previous state, as that would overwrite the latest navigation's
optimistic update. Now we check if the tab's current path still matches
what this request set before restoring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): use per-tab request ID to guard superseded navigation restores
Replace the ambiguous currentPath equality check with a per-tab
navigation request ID (tabNavSeqRef). The old check failed when
refresh() triggered a navigation to the same path — the stale request
would incorrectly match and restore previous state.
The new approach tracks the latest requestId per tab, so:
- Same-tab superseded navigations (including same-path refreshes)
correctly skip the restore.
- Cross-tab superseded navigations (different tab on the same side)
correctly restore the orphaned tab's state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): track per-tab nav sequence to prevent cache-hit state overwrite
When a cache-miss request (A) is pending and a cache-hit request (B) runs
on the same tab, A's superseded handler could overwrite B's result because
it only checked path equality. Add tabNavSeqRef to track the latest
requestId per tab, so superseded requests correctly skip restore when
a newer navigation (including cache hits) has already handled the tab.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: remove leftover merge conflict markers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): restore to last confirmed state instead of optimistic state
When multiple navigations are in flight (A→B→C), the second navigation
would snapshot the optimistic state (path=B, files=[]) as its "previous"
state. If it then failed or was superseded, it would restore to an empty
file list instead of the last successfully loaded directory.
Introduce lastConfirmedRef to track the last known-good state per tab,
updated only on successful navigation (cache hit or listing success).
Restore-on-error and restore-on-supersede now always revert to this
confirmed state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): guard restores against stale connection after reconnect/disconnect
connect() and disconnect() reuse the same tab ID but bump navSeqRef
without updating tabNavSeqRef, so a pending navigation could restore
stale state from a previous host into a freshly reconnected tab.
Fix by:
- Capturing connectionId at navigation start and checking it in every
updateTab restore callback (prev.connection?.id !== connectionId)
- Storing connectionId in lastConfirmedRef and re-seeding confirmed
state when the connection changes, preventing old host data from
being used as the restore target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): keep files visible during loading and re-seed confirmed state
Two UI regressions fixed:
1. After a file mutation (delete/create/rename), lastConfirmedRef still
held the pre-mutation snapshot. If the subsequent refresh failed, the
error handler would restore stale files (e.g. resurrecting deleted
items). Fix: re-seed confirmed state from the pane whenever it is
settled (not loading), capturing any optimistic mutation updates.
2. Clearing files to [] on navigation start left a tab blank when
superseded by another tab navigating on the same side. Fix: keep
existing files visible during loading — the loading overlay already
has pointer-events-none to prevent interaction. Files are replaced
on success or restored from lastConfirmedRef on error/supersede.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sftp): block interaction with stale files during directory loading
The loading overlay used pointer-events-none, allowing clicks to pass
through to stale file rows underneath. Since currentPath is updated
immediately on navigation, interacting with old filenames during a slow
load would resolve paths against the new directory.
Remove pointer-events-none from the loading overlay so it properly
blocks all interaction with the stale file list while loading.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: ignore .claude/ directory in eslint config
The .claude/worktrees/ directory contains full repo copies from agent
worktrees. ESLint was scanning these, causing 621 pre-existing errors
(no-undef for Node.js globals in .cjs files) that blocked npm run dev.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
checkNow was calling bridge.checkForUpdate() which invokes updater.checkForUpdates()
via IPC. startAutoCheck() in the main process already calls checkForUpdates() on a
5s timer, and if that network request is still pending, the concurrent IPC call from
checkNow hangs indefinitely, causing the UI to be stuck in "checking" state forever.
Per the original design spec, checkNow should use performCheck() (GitHub API) directly.
This is completely independent of electron-updater's internal state machine, so it
never conflicts with the background startAutoCheck(). performCheck handles isCheckingRef,
isChecking, hasUpdate, and latestRelease; checkNow only manages manualCheckStatus.
- Critical: In Linux fallback path, temporarily reset isCheckingRef before calling
performCheck so its own guard can run (was silently returning null due to double-set)
- Critical: Replace updateState.currentVersion closure in checkNow with currentVersionRef
to avoid reading stale '' version on early user click; remove from useCallback deps
- Important: Add explicit !result guard when bridge is unavailable, returning 'error'
status instead of silently falling through to 'up-to-date'
Only pass CSC_LINK, CSC_KEY_PASSWORD, and Apple notarization secrets
to the macOS matrix job. Previously these were passed to all matrix
jobs, causing electron-builder to sign Windows .exe with the Apple
Developer ID certificate. Windows doesn't trust Apple's certificate
chain, so electron-updater's signature verification failed during
auto-update.
Closes#309
Detailed step-by-step plan for feat/auto-update branch. Addresses
reviewer feedback: specific line anchors, SettingsSystemTab props
pattern, removeAllListeners risk, i18n key conflict notes, and
hasUpdate toast suppression when auto-download is active.
Spec for changing update flow from manual to auto-download + prompt
install: autoDownload=true in main process, renderer subscribes to
electron-updater IPC events, toast notification on download complete.
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.
- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.
Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:
1. ssh2-sftp-client's connect() installs error listeners that reject
the entire connection on ANY error, including non-fatal agent auth
failures. This prevents ssh2 from falling through to
keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
use direct ssh2.Client with err.level === 'agent' filtering.
2. getSshAgentSocket() on Windows unconditionally returned the agent
pipe path without checking if the SSH Agent service is running.
Fix: added async getAvailableAgentSocket() that runs
'sc query ssh-agent' before returning the pipe path.
Address Codex review: since the sync payload now includes
portForwardingRules, "Clear Local Data" must also reset them
to prevent stale rules from being re-uploaded on the next sync.
* feat: support system theme auto-switching\n\nAdd 'system' as a third theme option alongside 'light' and 'dark'.\nWhen set to 'system', the UI theme automatically follows the OS\ncolor scheme preference and switches in real-time when the system\nappearance changes.\n\nChanges:\n- useSettingsState.ts: Add resolvedTheme state derived from\n matchMedia('prefers-color-scheme: dark'), add listener for\n system preference changes, update applyThemeTokens to use\n resolvedTheme instead of theme directly\n- SettingsAppearanceTab.tsx: Replace dark mode Toggle with\n 3-segment selector (Light / System / Dark) using Sun/Monitor/Moon\n icons\n- en.ts/zh-CN.ts: Replace darkMode i18n keys with new theme keys\n including 'system' option\n- Default theme changed from 'light' to 'system' for new users\n\nPartially addresses #294
* fix: derive resolvedTheme synchronously and guard matchMedia\n\nAddress Codex review feedback:\n1. Replace resolvedTheme useState+useEffect with synchronous\n derivation from systemPreference state. This eliminates the\n one-frame stale render where useLayoutEffect could apply\n tokens from the old palette before useEffect updated\n resolvedTheme.\n2. Add window.matchMedia guard in the system preference listener\n to prevent crashes in jsdom tests or constrained webviews.\n3. Make the matchMedia listener unconditional (always tracks OS\n preference) to avoid setup/teardown churn when toggling modes.
* fix: resolve 'system' theme in pre-hydration bootstrap to prevent flash
The index.html bootstrap script only handled 'dark'/'light' stored
values. Since DEFAULT_THEME is now 'system', new users (or users who
chose system mode) would get a wrong-theme first paint until React
mounted. Now resolve 'system' via matchMedia('(prefers-color-scheme:
dark)') before applying the CSS class, eliminating the visible flash.
Also use the resolved theme (not raw stored value) for accent foreground
calculation to ensure correct contrast on first paint.
Addresses Codex review on PR #301.
* fix: use resolvedTheme for top-bar toggle to avoid no-op in system mode
When theme preference is 'system' and the OS is dark, the toggle button
showed a moon icon and clicking it just switched from 'system' to 'dark'
— visually a no-op. Now we:
1. Pass resolvedTheme (always 'light'|'dark') to TopTabs for icon display
2. Toggle based on resolvedTheme so the first click always produces a
visible change (e.g. system+dark → light, system+light → dark)
Addresses Codex review on PR #301.
* fix: re-run startup command on Start Over after SSH disconnect\n\nThe hasRunStartupCommandRef was set to true on first connection but\nnever reset when the user clicked Start Over (handleRetry). This\ncaused the startup command to be skipped on all subsequent retries.\n\nReset the ref to false in handleRetry so the startup command\nexecutes again on reconnection.\n\nPartially addresses #294
* fix: guard startup-command timer against stale sessions\n\nCapture the session ID when scheduling the startup command timer\nand verify it still matches sessionRef.current when the timer fires.\n\nThis prevents double execution when the user clicks Start Over\nquickly: the old timer detects the session ID mismatch and bails\nout, so only the new connection's timer runs the startup command.\n\nApplied to both SSH and Mosh startup command paths.
* fix: disable context menu in alternate screen to prevent tmux double menu\n\nWhen applications like tmux enable mouse mode in xterm's alternate\nscreen buffer, right-clicking would show both tmux's context menu\nand Netcatty's context menu simultaneously.\n\nThis fix detects alternate screen mode via xterm.js buffer.onBufferChange\nand disables Netcatty's context menu, letting the terminal application\nhandle mouse events natively.\n\nFixes #294 (Bug 1: Tmux duplicate context menus)
* refactor: use mouse tracking mode detection instead of alternate screen\n\nReplace alternate screen detection with mouseTrackingMode check.\nThis is more precise: context menu is only disabled when the terminal\napplication is actively capturing mouse events (e.g. tmux with\n`set -g mouse on`, vim with `set mouse=a`).\n\nPrograms that use alternate screen without mouse tracking (e.g.\nless, man, vim without mouse) will still show Netcatty's context menu.
* feat: add auto-update support via electron-updater (#289)
- Add autoUpdateBridge.cjs wrapping electron-updater for check/download/install
- Register bridge in main.cjs, expose IPC in preload.cjs
- Add auto-update methods to NetcattyBridge type in global.d.ts
- Extend updateService.ts with electron-updater bridge functions
- Add Software Update section in Settings > System tab with state machine UI
- Add i18n keys for update UI (en + zh-CN)
- Add publish config for GitHub Releases in electron-builder.config.cjs
- Update CI workflow to upload update metadata (*.yml, *.blockmap, *.zip)
- Fallback to manual GitHub download for unsupported platforms or errors
* fix: address Codex review - guard bridge call and pin sender window
- Guard optional bridge call in SettingsSystemTab to prevent TypeError
when getAppInfo is unavailable (e.g. browser/dev/test rendering)
- Capture senderWindow at download initiation in autoUpdateBridge so
progress/downloaded/error events always go to the requesting renderer,
even if focus changes during download
* fix: use semver ordering for version check and clean up listeners on rejection
- Replace strict equality (===) with localeCompare for version comparison
to avoid false positives on pre-release/nightly builds
- Clean up download-progress/update-downloaded/error listeners in the
catch path when downloadUpdate() rejects before emitting events
* feat: redirect update toast to Settings window for in-app update
- Update toast notification now opens Settings window instead of
GitHub Releases page, enabling the in-app download/install flow
- Add 'update.viewInSettings' i18n key (en + zh-CN)
- Remove unused openReleasePage from App.tsx destructuring
- Move useWindowControls() before the update effect to fix declaration order
* fix: cloud sync 401 Unauthorized on first app launch
Root cause: CloudSyncManager.initProviderDecryption() runs before the
Electron bridge (window.netcatty) is available. decryptField() silently
returns encrypted ciphertext as-is (no-op fallback), so tokens remain
encrypted. When checkRemoteVersion() fires, the adapter sends encrypted
ciphertext as the Bearer token → 401 Unauthorized.
Fix: Add a decryptionEffective flag to detect when decryption was a
no-op. In getConnectedAdapter(), retry decryption for the requested
provider if startup decryption failed due to bridge unavailability.
* fix: track actual decryption success instead of bridge function existence
The preload script sets up bridge functions before the main process
registers IPC handlers. Checking function existence is unreliable —
the function exists but the actual IPC call throws. Now we track
whether any decryption threw an error and only mark decryptionEffective
when decryption actually succeeds.
* fix: use per-provider decryption state instead of global flag
Address P1 review: with a single global decryptionEffective flag,
the first provider's successful retry would prevent retries for
other providers. Changed to providerDecrypted record so each
provider independently tracks its decryption status.
* fix: evict stale adapter after successful deferred decryption
Address P1 review: after deferred decryption succeeds, the old adapter
(built with encrypted ciphertext) was still cached. isAuthenticated
returns true for it because the ciphertext is a non-empty string, so
it kept being reused and returning 401. Now the stale adapter is signed
out and evicted, forcing a fresh one with decrypted credentials.
* fix: enable Windows PTY compatibility for local terminals
* fix: detect localhost local terminal sessions
* fix: improve Windows local shell defaults
* fix: align detected local shell with launcher
* fix: limit windows pty handling to local terminals
* fix: skip pwsh app execution alias shims
* feat(sftp): show download progress for "Open With" temp file downloads
When opening remote files via "Open With" or double-click, the download
to a temp directory now displays real-time progress (bar, speed, ETA) in
the transfer overlay instead of silently blocking until completion.
Reuses the existing transferBridge infrastructure (fastGet with throttled
IPC progress events) and the SftpTransferItem UI. Cancellation is handled
gracefully — the task transitions to "cancelled" status, the partial temp
file is cleaned up, and the file is not opened in the external application.
The original downloadSftpToTemp path is preserved as a fallback for
contexts without a transfer queue.
* fix(sftp): harden temp download transfer state
---------
Co-authored-by: midasgao <midasgao@distinctclinic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
* Fix SFTP directory copy into symlinked folders
* Honor SFTP directory drop targets
* Limit SFTP drop targeting to symlink directories
* Bind SFTP drops to the visible target pane
* Revert "Bind SFTP drops to the visible target pane"
This reverts commit d1bad223ffafd89d15217add8fbe4a24dda60433.
* Revert "Limit SFTP drop targeting to symlink directories"
This reverts commit edc67ed4a21c0c510854b5479592f4451d9b4cb7.
* Revert "Honor SFTP directory drop targets"
This reverts commit fed0d7bdd0f28fa6d4e9335f3964467b62921d7c.
* Stabilize SFTP directory transfer progress
* Enable compressed uploads in SFTP view
* Fix directory transfer cancellation and total growth
* Keep prescan cancellation in transfer cleanup
* Sync compressed uploads and persistent cancellation
* Tighten SFTP cancellation cleanup
* Handle Windows SFTP directory paths
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround and DMG background image
- Pass signing and notarization secrets in CI build step
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround from DMG (no longer needed
with proper code signing)
- Pass signing and notarization secrets in CI build step
- Shrink DMG window to fit the simpler two-icon layout
The debian:bullseye container introduced in v1.0.39 broke native module
packaging — node-pty's .node binary was missing from app.asar.unpacked,
causing 'No such file or directory' on ArchLinux and other x64 distros.
Revert to the v1.0.38 approach: build x64 directly on ubuntu-latest
with setup-node. ARM64 keeps the Debian container for GLIBC compat.
Closes#264
* fix: await provider token decryption before creating sync adapters
On cold start, initProviderDecryption() runs async in the constructor
but getConnectedAdapter() could be called before it finished, causing
adapter creation with still-encrypted tokens to fail silently.
Store the decryption promise and await it in getConnectedAdapter() so
tokens are guaranteed to be decrypted before use.
* fix: auto-recover sync providers stuck in error status
When syncAllProviders runs, providers with status 'error' that still
have tokens/config are now reset to 'connected' and their cached
adapter is invalidated, allowing a fresh retry with current (decrypted)
tokens. This prevents the permanent 'not configured' state that
previously required opening Settings to clear.
- Check session.stream in setSessionEncoding to reject non-SSH sessions
that share the sessions map (local/telnet/serial)
- Add hostname !== 'localhost' guard to isSSHSession in toolbar and
onSessionAttached, since localhost routes through startLocal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Check that sessionId exists in the sessions map before writing to
sessionEncodings/sessionDecoders, preventing stale map entries and
misleading ok:true responses for disconnected sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move encoding sync from updateStatus("connected") to a new
onSessionAttached callback in attachSessionToTerminal, which fires
right after sessionRef is set but before the data listener is
registered. This ensures the first data chunk is decoded correctly
even if the user changed encoding during connection.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove hostname==='localhost' check since SSH to localhost is valid
and local protocol sessions are already filtered by isLocalTerminal
- Restrict updateStatus encoding sync to SSH sessions only, preventing
stale decoder entries from accumulating for non-SSH session types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove utf-8 guard from connect-time sync so GB-preseeded hosts that
get switched to UTF-8 during connect are synced correctly
- Exclude hostname==='localhost' sessions from encoding popover since
they route through startLocal, not the SSH bridge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If the user changes encoding while still connecting, sessionRef is null
so the IPC call is skipped. Now updateStatus syncs the encoding to the
backend when status transitions to 'connected' and encoding is non-default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mosh sessions keep host.protocol as 'ssh' but set host.moshEnabled,
so also gate encoding popover on !host?.moshEnabled.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace per-chunk iconv.decode() with stateful iconv.getDecoder() to
handle multibyte characters split across packet boundaries (P1)
- Reset decoders when encoding is switched mid-session
- Gate encoding popover to SSH sessions only, excluding Telnet/Mosh (P2)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow users to switch between UTF-8 and GB18030 encoding mid-session
via a toolbar popover, fixing garbled output when viewing mixed-encoding
logs on remote servers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ssh2 throws when agentForward=true but no agent path is set. Move the
agentForward assignment after the agent availability check so forwarding
is silently skipped when the agent is unavailable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On Windows, the agent socket path was set unconditionally to
\\.\pipe\openssh-ssh-agent even when the ssh-agent service is not
running. This caused "Failed to connect to agent" errors and prevented
fallback to keyboard-interactive auth (password prompt).
Now uses the existing checkWindowsSshAgent() to verify the service is
running before setting the agent path, allowing auth to fall through to
keyboard-interactive when no keys or password are configured.
Closes#258
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace per-instance useState with useSyncExternalStore backed by a
module-level singleton so all mounted local SFTP panes share the same
bookmark state and writes never overwrite each other.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split on both / and \ so the label extracts correctly for paths
like C:\Users\damao\Documents → "Documents".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Local SFTP panes now support directory bookmarks, stored in localStorage
since there is no Host object for local connections.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When agent forwarding is enabled, the session uses an SSH agent which
may hold encrypted keys. Don't classify such sessions as password-only
to preserve the encrypted key retry path.
Addresses P2 review feedback on #269.
When jump hosts are configured, the auth error could originate from a
key-based bastion rather than the password-only final target. Skip the
passphrase prompt bypass when jump hosts are present to ensure encrypted
default keys can still be offered for the chain.
Addresses review feedback on #269.
When a host is configured with username+password (no SSH key), the app
incorrectly prompted for local SSH key passphrases because:
1. buildAuthHandler added default ~/.ssh/ keys and ssh-agent as fallback
methods for password-only connections, causing unnecessary key probing
2. startSSHSessionWrapper unconditionally scanned for encrypted default
keys on auth failure and showed passphrase modal
Fix by:
- Removing default key/agent fallback from password-only auth handler
- Skipping encrypted key passphrase prompt in retry logic when the user
explicitly configured password authentication
Fixes#266
The Linux x64 AppImage was missing the compiled node-pty native module
(pty.node), causing the app to crash on launch. This happened because
the bare ubuntu-latest runner lacked build-essential/python3 needed by
node-gyp to compile native addons.
Move the Linux x64 build into a dedicated job using debian:bullseye
container (matching the ARM64 job) which:
- Installs build-essential, python3 and other native build deps
- Ensures node-pty, ssh2, cpu-features compile correctly
- Pins GLIBC to 2.31 for broader distro compatibility
Fixes#264
The regex for parsing the distro ID from /etc/os-release only matched
unquoted values like `ID=ubuntu`, but RHEL uses `ID="rhel"` with
double quotes. The new regex `/^ID="?([\w-]+)"?$/im` handles both
quoted and unquoted forms.
Fixes#263
- Export requireSftpChannel from sftpBridge for cross-module use
- Add channel recovery to uploadWithStreams, downloadWithStreams,
and startTransfer stat call in transferBridge
- Clean up verbose debug console.logs in cancelTransfer
The mkdirSftp handler delegates to ensureRemoteDirForSession, which
had the same issue as deleteSftp — the UTF-8 branch called
client.mkdir() directly without validating the channel first.
- Fix per-client dedup: store _reopeningPromise on client object
instead of module-level global to prevent cross-session confusion
- Narrow isSessionError patterns: replace overly broad "not found"
and "closed" with specific "channel closed"/"connection closed",
add "timed out" for channel open timeout errors
- Prevent channel leak on timeout: close orphaned SFTP channel
when tryOpenSftpChannel callback fires after timeout
- Auto-reload directory listing after successful reconnect in
SFTP modal to avoid stale UI state
P1 fixes:
- Add requireSftpChannel() to all SFTP operations: readSftp,
readSftpBinary, writeSftp, writeSftpBinary,
writeSftpBinaryWithProgress, renameSftp, statSftp, chmodSftp,
and deleteSftp UTF-8 branch
- Add 10s timeout to tryOpenSftpChannel to prevent hang when
SSH connection is half-dead
P2 fixes:
- Deduplicate concurrent getSftpChannel calls to avoid redundant
channel re-opens
- Refactor isFatalUploadError to compose with isSessionError,
eliminating pattern duplication and drift risk
- Add "write after end" and "no response" patterns to the shared
isSessionError() in errors.ts
- Replace inline duplicate in useSftpModalSession with an import
of the shared function
- Remove stale isSessionError from useCallback dependency array
Real directories cannot form cycles, so remove depth limit for them.
Only track and limit symlink-directory nesting (MAX_SYMLINK_DEPTH=32)
to prevent cycles like `loop -> .` while allowing legitimate deep
directory structures to download without error.
Add activeChildTransferIdsRef (Map<parentId, childId>) to track the
currently in-flight child transfer for directory downloads. cancelTask
now cancels both the parent ID and the active child transfer ID,
making folder download cancellation immediate and reliable.
- Throw error when MAX_RECURSION_DEPTH exceeded instead of silently
returning, so download is marked failed with a clear message (P1)
- Hide folder download context menu item for local sessions where
handleDownload only supports files (P2)
SFTP doesn't expose realpath, so raw path strings can't detect cycles
like `loop -> .` that produce unique paths each level. Add a hard
MAX_RECURSION_DEPTH=32 guard alongside the existing visitedPaths set
to reliably prevent unbounded recursion.
- Add visitedPaths Set to prevent infinite recursion from symlink
cycles (e.g. symlink to parent directory)
- Handle undefined result from startStreamTransfer (bridge unavailable)
by rejecting immediately instead of hanging indefinitely
- Handle resolved result.error from startStreamTransfer to prevent
hung Promises on cancellation (P1)
- Only ignore EEXIST from subdirectory mkdirLocal, propagate other
errors like permission failures (P2)
- Cancel active child transfer from onProgress callback immediately
when parent folder download is cancelled (P1)
- Handle symlink -> directory entries in recursive descent so they
are treated as directories instead of files (P2)
- Revert mkdirLocal to safe original (no silent file deletion)
- Move EEXIST handling to download-overwrite flow only (deleteLocalFile)
- Add cancellation support for recursive folder downloads:
- Track active child transfer ID for cancellation
- Check cancelledTransferIdsRef between files
- Cancel in-flight child transfer when parent is cancelled
- Fix new folder input not resetting after deletion (SftpPaneToolbar/View)
- Fix folder download stuck at 95% by replacing simulated progress with real child-file progress tracking (useSftpTransfers)
- Add download menu item for directories in SFTP modal context menu (SftpModalFileList)
- Implement recursive folder download in SFTP modal with real-time progress (useSftpModalTransfers, SFTPModal)
- Fix mkdirLocal EEXIST error when target path is an existing file (localFsBridge)
- Close settings window when main window is minimized to tray (windowManager)
Closes#254
* fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat\n\nSplit linux-arm64 out of the build matrix into a dedicated job that\nruns inside a debian:buster container (GLIBC 2.28) on the ARM64 runner.\nThis ensures the compiled node-pty native module is compatible with\nolder distros like UOS/Deepin.\n\nCloses #253
* fix(ci): use archive.debian.org for EOL Buster repos
* fix(ci): switch to debian:bullseye for Python 3.9 + GLIBC 2.31 compat\n\nBuster's Python 3.7 is too old for node-gyp@11 (walrus operator).\nBullseye provides Python 3.9 and GLIBC 2.31 which is still below\nthe critical 2.34 boundary (libpthread merge into libc).
Use a dedicated step with `if` condition so npm_config_arch is only
set for linux-arm64. The previous approach set it to an empty string
for other jobs, which could interfere with node-gyp arch detection
on macOS, Windows, and linux-x64 builds.
On ubuntu-24.04-arm runners, electron-builder's post-build
@electron/rebuild incorrectly tries to restore native modules
to x64 architecture. The ARM64 g++ compiler doesn't support the
-m64 flag, causing the build to fail.
Setting npm_config_arch=arm64 ensures node-gyp correctly identifies
the host architecture, preventing the erroneous x64 rebuild.
* 修复 SSH 证书认证问题,增强日志以调试证书解析和签名过程。
* fix: clean up ssh2 patch and optimize netcattyAgent\n\n- Remove ~1187 lines of build artifacts from ssh2+1.17.0.patch\n (Makefile, config.gypi, .o binaries, sshcrypto.node etc. with\n hardcoded /Users/idouying paths). Keep only meaningful patches:\n client.js, Protocol.js, SFTP.js\n- Cache parsed private key during Agent construction to avoid\n re-parsing on every sign() call\n- Fix missing space in comment
* chore: revert package-lock.json noise and fix trailing whitespace\n\n- Revert package-lock.json to main (peer flag changes were noise\n from different Node.js version, not intentional)\n- Fix trailing whitespace in netcattyAgent.cjs
---------
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
* feat: implement custom terminal themes with .itermcolors import (#228)\n\n- Add customThemeStore with CRUD operations and localStorage persistence\n- Create .itermcolors parser with RGB-to-hex conversion and auto theme type detection\n- Add CustomThemeEditor component with inline color pickers\n- Refactor ThemeCustomizeModal with Custom tab for create/import/edit/delete\n- Update all theme consumers (Terminal, TerminalLayer, LogView, ThemeSelectPanel,\n SettingsTerminalTab, useSettingsState) to resolve custom themes\n- Add i18n keys for custom theme features (en + zh-CN)\n- Add isCustom flag to TerminalTheme model and STORAGE_KEY_CUSTOM_THEMES constant
* feat: add import .itermcolors button to Settings Terminal tab\n\nAllows importing .itermcolors files directly from Settings → Terminal\nwithout opening the theme modal first.
* fix: move delete button from editor to modal footer for clean layout\n\nThe delete button was rendering inside the CustomThemeEditor left panel,\ncausing misalignment with the full-width Cancel/Save footer. Now the\nfooter shows: [Delete] (left) | [Cancel] [Save] (right) when editing\nan existing custom theme.
* refactor: extract custom theme editor into standalone modal\n\nThe inline CustomThemeEditor was causing layout conflicts in the\nThemeCustomizeModal (editor + footer overlapping). Extracted into\na dedicated CustomThemeModal with:\n- Two-column layout: editor panel (left) + terminal preview (right)\n- Own footer: Delete (left) | Cancel + Save (right)\n- z-index 300 layering above the main theme modal\n- Proper scroll containment for the color editor
* fix: correct z-index stacking for custom theme modal\n\nRemoved inline style zIndex: 99999 from ThemeCustomizeModal that was\npushing it above CustomThemeModal. Now uses Tailwind z-[200] for the\nmain modal and z-[300] for the custom theme editor modal.
* feat: add new custom theme button to settings terminal tab\n\nReuses CustomThemeModal from the settings page. Creates a new theme\nbased on the currently selected theme, opens the editor modal, and\nautomatically selects the new theme on save.
* feat: add cross-window IPC sync for custom themes\n\nCustom themes created/imported/deleted in the Settings window are now\nimmediately synced to the main window (and vice versa) using the\nexisting netcatty:settings:changed IPC channel. Each mutation\nbroadcasts the change, and each window listens for incoming changes\nand reloads themes from localStorage.
* fix: show custom themes in ThemeSelectModal\n\nThemeSelectModal was only displaying built-in TERMINAL_THEMES.\nNow imports useCustomThemes hook and renders custom themes in a\nseparate section at the bottom of the theme list.
* feat: add edit/delete buttons for custom themes in settings\n\nWhen a custom theme is selected, Edit and Delete buttons appear next\nto the New/Import buttons. Edit opens the CustomThemeModal in edit\nmode, Delete removes the theme and falls back to the default theme.
* refactor: remove redundant header from CustomThemeEditor\n\nThe inner header with back arrow and title was duplicating the\nparent CustomThemeModal header. Removed the header block,\nArrowLeft import, and prefixed unused props with underscore.
* fix: add missing common.edit i18n key\n\nAdded 'Edit' / '编辑' translations for the common.edit key\nthat was showing as raw key text in the Settings page.
* fix: add error feedback for .itermcolors import in settings\n\nAdded step-by-step console logging for debugging import issues.\nShows user-visible alert on parse failure with localized message.\nAlso added terminal.customTheme.importError i18n keys.
* fix: handle extra keys in .itermcolors color dicts\n\nThe parseColorDict function assumed keys[i] aligned with reals[i],\nbut .itermcolors files with extra keys like 'Alpha Component' (real)\nand 'Color Space' (string) broke the index mapping.\n\nNow iterates through dict children properly, pairing each <key>\nwith its next sibling and skipping non-<real> values.
* fix: subscribe to custom theme store for reactive re-renders\n\nReplaced imperative customThemeStore.getThemeById() calls with reactive\nuseCustomThemes() hook in useMemo dependencies across 5 files:\n- useSettingsState.ts (currentTerminalTheme)\n- Terminal.tsx (effectiveTheme for host-override)\n- TerminalLayer.tsx (composeBarThemeColors)\n- LogView.tsx (currentTheme for log replay)\n- SettingsTerminalTab.tsx (currentTheme)\n\nThis ensures editing a custom theme in-place (same ID) triggers\nre-renders in all consuming components, instead of showing stale colors\nuntil the user switches theme IDs or reloads.
* fix: theme editor hex validation, import error feedback, and Escape propagation\n\n1. ColorInput: Use local state for text field so partial hex values\n (#1, #abc) are held locally while typing. Only complete #rgb (auto-\n normalized to #rrggbb) or #rrggbb values are committed to the theme.\n On blur, partial values revert to the last valid color.\n\n2. ThemeCustomizeModal handleFileSelected: Added error feedback via\n window.alert when .itermcolors parsing fails, reusing the existing\n terminal.customTheme.importError i18n key. Also extended filename\n regex to strip .xml extension.\n\n3. ThemeCustomizeModal Escape handler: Skip parent modal cancelation\n when editingTheme is active, so pressing Escape only closes the\n child CustomThemeModal without reverting the parent dialog.
* fix: backdrop click closes CustomThemeModal + remove nested buttons in ThemeItem\n\n1. CustomThemeModal: Attach onClick={onCancel} directly to the backdrop\n div instead of checking e.target === e.currentTarget on the container.\n The modal content div now stops event propagation to prevent\n accidental dismissal when clicking inside the dialog.\n\n2. ThemeItem: Replace outer <button> with <div role=\"button\"> and inner\n edit <button> with <div role=\"button\"> to eliminate invalid nested\n interactive elements. Added keyboard handlers (Enter/Space) for\n accessibility parity.
* fix: restore Escape key in CustomThemeModal + stabilize store snapshots\n\n1. CustomThemeModal: Add Escape key handler (capture phase) so pressing\n Escape dismisses the child editor. Fixes regression where parent\n ThemeCustomizeModal skips Escape when editingTheme is active but\n the child had no handler of its own.\n\n2. customThemeStore: Cache the merged allThemes array (built-in +\n custom) and only rebuild it when the store is mutated. The previous\n getAllThemes() created a new array every call, violating the\n useSyncExternalStore contract that getSnapshot must return a stable\n reference between mutations.
* fix: accept <integer> plist nodes and guard NaN in itermcolors parser\n\nparseColorDict now accepts both <real> (float 0.0-1.0) and <integer>\n(0-255) plist value types for RGB components. Integer values are\nnormalized by dividing by 255. Also added isNaN guard on parseFloat\nresults to prevent malformed '#NaNNaNNaN' color strings from being\npersisted as custom themes.
* fix: use customThemeStore.getThemeById in HostDetailsPanel\n\nHostDetailsPanel used TERMINAL_THEMES.find() for both SSH and Telnet\ntheme previews, which only searched built-in themes. When a custom\ntheme was selected for a host, the preview fell back to Flexoki Dark\ndefaults. Now uses customThemeStore.getThemeById() which searches\nboth built-in and custom themes.
* chore: remove fake user counts from ThemeSelectPanel\n\nRemoved Math.random() generated fake user counts for Kanagawa and\nHacker themes, 'new' badges for Flexoki themes, and the Users icon.\nOnly meaningful labels remain: 'Default' for netcatty-dark and\n'Light mode' for netcatty-light.
* feat: add compose bar for pre-composing commands (#198)\n\nAdd an XShell-style compose bar at the bottom of each terminal.\nThe bar lets users type and review commands before sending,\nwhich is helpful for password prompts (no echo) and complex\ncommands. When broadcast mode is active the composed text\nis sent to all sessions in the workspace.\n\nNew files:\n- TerminalComposeBar.tsx (auto-sizing textarea, Enter/Shift+Enter/Esc)\n\nModified:\n- TerminalToolbar.tsx — toggle button (TextCursorInput icon)\n- Terminal.tsx — state, send handler, flex-col layout\n- en.ts / zh-CN.ts — i18n strings"
* refactor: modernize compose bar styling and add global workspace bar\n\n- Rewrite TerminalComposeBar with modern styling: gradient background,\n rounded bottom corners (8px), themed focus rings, native hover buttons\n- In workspace mode, render a single global compose bar at the bottom\n of TerminalLayer instead of per-terminal bars\n- Non-broadcast: sends to the currently focused terminal session\n- Broadcast mode: sends to all sessions in the workspace\n- Add onToggleComposeBar/isWorkspaceComposeBarOpen props for\n toolbar-to-TerminalLayer communication"
* fix: vertically center compose bar buttons and increase button contrast\n\n- Change flex alignment from items-end to items-center\n- Increase button background opacity (8%→20% for send, 0→12% for close)\n- Use solid bg color-mix instead of transparent for better visibility"
* fix: increase compose bar border contrast and fix IME composition\n\n- Increase border opacity from 12% to 25% (unfocused) and 25% to 40% (focused)\n- Add onCompositionStart/End handlers to prevent Enter key from\n triggering send while IME composition is active (Chinese input)\n- Remove unnecessary wrapper div around textarea for better flex alignment"
* fix: refocus terminal when closing workspace compose bar\n\nAfter closing the compose bar in workspace mode, focus is now restored\nto the focused terminal pane via its xterm-helper-textarea, matching\nthe solo-session behavior. Uses requestAnimationFrame to ensure the\nDOM update completes before focusing."
* fix: fallback to first session when focusedSessionId is missing\n\nWhen broadcast is disabled and focusedSessionId is null (e.g. stale\nworkspace data), the compose bar now falls back to the first available\nsession in the workspace instead of silently dropping the input."
* fix: validate focusedSessionId is a live session before sending\n\nAfter closing a pane, focusedSessionId may point to a stale session.\nNow validates that focusedSessionId exists among the workspace's live\nsessions before using it, falling back to the first available session."
* feat: add bracketed paste mode toggle (#233)
Add a setting to disable bracketed paste mode, which prevents
^[[200~ artifacts in terminals that don't support it.
- Add disableBracketedPaste field to TerminalSettings
- Wire to xterm.js ignoreBracketedPasteMode option
- Add toggle in Settings > Terminal > Behavior
- Add en/zh-CN translations
* fix: update bracketed-paste option on live terminals
Apply ignoreBracketedPasteMode at runtime via the terminal settings
sync useEffect, so flipping the toggle takes effect immediately on
active sessions without requiring a reconnect.
* fix: respect disableBracketedPaste in all manual paste paths
The xterm.js ignoreBracketedPasteMode option only affects xterm's
own paste handling, not the modes getter. The 3 manual paste wrappers
(hotkey, context menu, middle-click) still checked
term.modes.bracketedPasteMode which reports true regardless of the
option. Now all 3 paths also check the user setting before wrapping.
Add overflow-hidden to AsidePanelContent inner wrapper to prevent
long text (like proxy hostnames) from expanding the panel beyond
its fixed width. The Radix ScrollArea Viewport allows content to
grow horizontally; this clips it at the container boundary.
Redesign the proxy configuration card to match the Jump Hosts and
Environment Variables pattern:
- When configured: clickable summary card with proxy type badge,
address, and X button to clear
- When unconfigured: simple + button to configure
- Removes cramped Badge-next-to-title layout that caused text wrapping
* fix: filter dotfiles as hidden on Linux/Unix systems (#194)
Previously the hidden file filter only checked the Windows hidden
attribute, leaving Unix/Linux dotfiles (starting with '.') always
visible regardless of the "show hidden files" setting.
- Rename isWindowsHiddenFile to isHiddenFile with both checks
- Add dotfile detection (name.startsWith('.'))
- Keep backward-compatible alias for isWindowsHiddenFile
- Update filterHiddenFiles to use the new isHiddenFile function
* fix: limit dotfile filtering to remote connections only
Address review feedback: dotfile filtering was applied unconditionally,
which would hide .gitignore, .env, etc. on local Windows panes.
- Add isLocal param to isHiddenFile/filterHiddenFiles
- When isLocal=true, only check Windows hidden attribute
- When isLocal=false (remote SFTP), also filter dotfiles
- Update all 3 callers to pass connection.isLocal
- Fix useMemo dependency arrays
* fix: preserve isWindowsHiddenFile backward compatibility
isWindowsHiddenFile alias now explicitly passes isLocal=true to
isHiddenFile, so existing callers that don't pass isLocal won't
accidentally filter dotfiles.
The ARM64 AppImage contained x86-64 native modules (node-pty, ssh2)
because both architectures were built on the same x86 runner.
- Split Linux build into linux-x64 (ubuntu-latest) and linux-arm64
(ubuntu-24.04-arm) jobs so native modules compile on the correct arch
- Add pack:linux-x64 and pack:linux-arm64 npm scripts with explicit
--x64/--arm64 flags
- Unify CI build step using matrix variables instead of per-OS conditions
* feat: add SFTP path bookmarks for dual-pane view
- Add SftpBookmark interface and sftpBookmarks field to Host model
- Create useSftpBookmarks hook with toggle/delete/list operations
- Add updateHosts callback through SftpContext for persistence
- Add bookmark star button with Popover dropdown in SftpPaneToolbar
- Wire bookmarks from App.tsx → SftpView → SftpContextProvider → SftpPaneView
- Add i18n translations for en and zh-CN
Closes#193
* refactor: replace encoding Select with compact icon Popover in SFTP toolbar
Replace the wide Select dropdown for filename encoding with a compact
Languages icon button + Popover menu, matching the SftpModal style.
* feat: add bookmark support to SFTPModal with shared hook
Refactor useSftpBookmarks to accept host/onUpdateHost params directly
instead of reading from SftpContext, enabling reuse in both SftpPaneView
(dual-pane) and SFTPModal (terminal).
- Refactor useSftpBookmarks hook to be context-agnostic
- Add bookmark star + Popover UI to SftpModalHeader
- Wire onUpdateHost from Terminal.tsx through SFTPModal
- Update SftpPaneView to use the new hook interface
useSftpHostCredentials.ts omitted `passphrase` when building the
credentials object for the target host, causing SFTP connections with
passphrase-protected private keys to fail with:
Error: Cannot parse privateKey: Encrypted private OpenSSH key
detected, but no passphrase given
The jump host path (L50) already included passphrase correctly.
This adds the same pattern to the main host credentials.
Bug 2: Replace prompt() with state-based dialog for new file/folder
- Electron does not support window.prompt() (returns null)
- Added create dialog following the existing rename dialog pattern
- Dialog renders in SftpModalDialogs with proper input + submit
Bug 3: Add Chinese translations for shortcut key labels
- SettingsShortcutsTab now uses t() for binding labels with fallback
- Added 29 Chinese translations for all keyboard shortcut bindings
- Reverse transfer list in dual-pane SftpView (visibleTransfers)
- Reverse transfer list in sidebar SftpModalUploadTasks
- Newest/active transfers now appear at the top without scrolling
* fix(sftp): prevent stale session race when reopening modal
* fix(sftp): close session on external modal hide
* fix(sftp): clean up late-created sessions after modal hide
* feat: add credential protection guards for enc:v1: placeholders
Prevent encrypted credential placeholders from being sent as
actual passwords when safeStorage decryption is unavailable
(e.g. different device/user profile). Adds guards at terminal
connection, cloud sync, and settings boundaries with user-facing
warnings and i18n support.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate base64 format in encrypted credential detection
Only treat values as encrypted placeholders when the content after
the enc:v1: prefix is valid base64. Prevents false positives if a
real password happens to start with the prefix literal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve regressions in master-key change flow and credential placeholder detection
- Make ensureSyncablePayload non-blocking in changeMasterKey handler so
success toast and dialog close always fire after a successful key change,
even when the payload contains unresolved enc:v1: placeholders
- Add MIN_CIPHERTEXT_BASE64_LENGTH (32) threshold to
isEncryptedCredentialPlaceholder to avoid false-positive matches on
plaintext credentials that happen to start with enc:v1: (e.g. enc:v1:hello)
* fix: clean up chain-progress listener on credential reentry and gate proxy check on auth usage
- Unsubscribe onChainProgress before returning in needsCredentialReentry
branch to prevent listener leaks across connection attempts
- Only block connection for undecryptable proxy password when proxy
authentication is actually in use (has a username)
* fix: reduce enc:v1 placeholder false positives
* fix: require syncable payload before master key rotation
---------
Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Collect SwapTotal and SwapFree from /proc/meminfo and display swap
usage in the memory HoverCard with a dedicated progress bar (rose color).
Only shown when the server has swap configured (swapTotal > 0).
Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Initial plan
* fix: remove extra right spacing next to close button on Windows
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.
- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region
macOS layout is unchanged.
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.
- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region
macOS layout is unchanged.
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
- CloudSyncManager: bump providerWriteSeq on storage events so an
in-flight local save is discarded when newer cross-window data arrives
- useVaultState: defer reads of keys/identities/groups/snippets to just
before their processing stage instead of reading all upfront, so data
updated during a prior async decrypt gap is not overwritten by a stale
pre-await snapshot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- useVaultState: storage-event decrypt callbacks now also check
writeVersion so a local edit during the decrypt gap causes the stale
cross-window result to be discarded
- CloudSyncManager: bump providerDecryptSeq in uploadToProvider before
mutating lastSync/lastSyncVersion so a pending cross-window decrypt
cannot overwrite the newer sync metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split the single providerSeq into providerDecryptSeq (bumped by all state
mutations to guard async decrypt callbacks) and providerWriteSeq (bumped
only by saveProviderConnection). This prevents status-only transitions
like 'error' or 'syncing' from discarding an in-flight encrypted write
from disconnect/auth, which would leave stale credentials in localStorage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CloudSyncManager: add providerSeq write guard to saveProviderConnection
so overlapping async saves don't let an older encryption overwrite newer
provider state in localStorage
- credentialBridge: verify enc:v1: prefix by attempting trial decryption
instead of prefix-only check, so plaintext values that happen to start
with the sentinel are still encrypted rather than silently skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move hostsWriteVersion/keysWriteVersion/identitiesWriteVersion increments
to before the await decryptHosts/Keys/Identities calls, and guard both
setstate and re-encrypt with the version check. This prevents a write
that occurs during the decryption await (storage event, user edit) from
being overwritten by stale init data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- useVaultState: bump writeVersion counters on storage events so pending
local encrypts are discarded when newer cross-window data arrives
- CloudSyncManager: bump providerSeq on all local provider mutations
(connect, disconnect, status updates, save) so in-flight decrypt
callbacks from startup or storage events cannot revert newer state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevent out-of-order async decrypt results from overwriting newer state:
- useVaultState: add per-key readSeq counters for cross-window storage
event decrypt callbacks (hosts, keys, identities)
- CloudSyncManager: add per-provider sequence counters shared between
initProviderDecryption and cross-window storage handler, so stale
decrypt results are discarded in both paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses remaining Codex review feedback:
- Add writeVersion checks to startup migration re-encrypt paths to prevent
stale async writes from overwriting newer user edits
- Move `prev` read inside .then() in CloudSyncManager storage event handler
so it compares against latest state rather than a stale snapshot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Race condition: rapid updateHosts/Keys/Identities calls could cause
out-of-order async writes. Added per-collection write-version counters
so only the latest encryption Promise persists to localStorage.
2. WebDAV token-auth: using "password" in config as discriminator failed
for token-auth configs because JSON.stringify drops undefined keys.
Switched to "authType" in config which is a required WebDAVConfig field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Destroy trayPanelWindow and clear refresh timer during cleanup, preventing
hidden BrowserWindows from keeping the Electron process alive
- Add SIGTERM/SIGINT handlers for graceful shutdown
- Detect crashed webContents in focusMainWindow() and recreate the window
instead of silently failing on second-instance activation
Closes the issue where restarting the app shows "Failed to load the UI"
and leaves multiple zombie processes in the task manager.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: tray quit button, tree view multi-select, and SFTP banner handling
- Add "Quit Netcatty" button pinned to the bottom of TrayPanel so users
can exit the app when close-to-tray is enabled
- Support multi-select mode in HostTreeView (checkboxes, click-to-select)
so tree view behaves the same as grid/list views
- Patch ssh2 SFTP parser to skip non-SFTP preamble data (MOTD/banner text)
that causes "Packet length exceeds max length" errors on misconfigured
servers, with proper cross-frame buffering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: gate SFTP preamble scan to client-mode only
Server-mode SFTP expects SSH_FXP_INIT (0x01) as the first packet, not
SSH_FXP_VERSION (0x02). Skip the preamble scan entirely when running in
server mode to avoid stalling server-side SFTP sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Initial plan
* Fix ERR_FAILED when second instance launches by moving single-instance lock before app.whenReady()
Move app.requestSingleInstanceLock() before app.whenReady() registration
and wrap all lifecycle handlers (whenReady, window-all-closed, before-quit,
will-quit) inside the else block. This prevents a second instance from
attempting to register the app:// protocol or create a BrowserWindow,
which would fail with ERR_FAILED.
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
All three paste paths (hotkey, context menu, middle-click) were sending
raw clipboard text directly to the session backend via writeToSession(),
bypassing xterm's built-in term.paste() which handles bracketed paste
wrapping. When a remote application like vim enables bracketed paste
mode (CSI ?2004h), pasted text must be wrapped in \e[200~ / \e[201~
so the application can distinguish paste from typed input.
Without these markers, vim's autoindent treats each pasted newline as
a manual Enter keypress, causing indentation to accumulate
progressively with each line (the "staircase effect").
Now checks term.modes.bracketedPasteMode before sending and wraps
the text accordingly on all paste paths.
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: restore built-in editor paste reliability
* fix: prevent Cmd+R window reload while editing in Monaco
Replace the Electron menu `{ role: "reload" }` with a manual click
handler so that Cmd+R no longer registers as a native accelerator.
This prevents accidental window reloads (and loss of unsaved edits)
when the text editor has focus, since the app's hotkey early-return
skips preventDefault for editor surfaces.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: fall back to Monaco native paste when clipboard read is unavailable
When both navigator.clipboard.readText() and the Electron bridge fail,
readClipboardText now returns null instead of '' so handlePaste can
distinguish "read failed" from "clipboard empty" and trigger Monaco's
built-in paste action as a fallback.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: let clipboard bridge errors propagate for proper paste fallback
useClipboardBackend.readClipboardText was swallowing bridge
absence/errors as "", making TextEditorModal's catch-based null
fallback unreachable. Now throws when the bridge is unavailable or
the call fails, so the caller can detect failure and fall back to
Monaco's native paste action.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: preserve multi-cursor paste distribution semantics
When multiple cursors are active and the clipboard line count matches
the cursor count, distribute one line per cursor instead of pasting
the full text at every cursor. This matches Monaco's default
multicursorPaste:'spread' behavior.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Introduces a collapsable sidebar in the Vault view to enhance user experience and optimize screen real estate.
The sidebar's collapsed or expanded state is now persisted across sessions using local storage. Tooltips are added to navigation items, providing clear labels when the sidebar is in its collapsed state.
Additionally, improves tooltip activation behavior in the SFTP modal, delaying their appearance slightly to prevent flickering on modal open.
Add storage event listener in usePortForwardingState to sync rules between
main window and TrayPanel. When port forwarding rules are added, updated,
or deleted in the main window, the TrayPanel now receives the update via
localStorage storage event and re-renders accordingly.
Also fix TypeScript type inference in normalizeRulesWithConnections by
adding explicit return type annotations.
- Fix handlerJump/handlerConnect signatures to match preload callback
(preload strips event, callback receives only sessionId/hostId)
- Use windowManager.getMainWindow() for reliable main window reference
- Add fallback filtering for tray panel and destroyed windows
- Group sessions by workspaceId in collapsible WorkspaceGroup component
- Add workspaceId/workspaceTitle fields to tray session types
- Update App.tsx to send workspace data in updateTrayMenuData
- Fix session focus handlers to navigate to workspace and focus session
- Align bridge typings for consistent workspace session fields
- Fix main window listeners by using netcatty bridge events (not window.electron)
- Remove recent sessions section; show only current active tab entry
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Tray panel click now asks main process to open main window and send jump/connect events
- Main window listens for these events and switches tab or starts a new connection
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Show connected/connecting sessions for quick jump to tab
- Add recent hosts (last 5) and allow starting a new session from tray panel
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Periodically signal tray panel to refresh while visible
- Add preload/api typings + backend hook support for refresh events
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Wrap tray panel with I18nProvider so translations render
- Add colored status dots and spinner for connecting
- Stop setting tray context menu to avoid Quit showing alongside panel
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Keep tray context menu minimal to avoid menu + panel overlap
- Add tray panel i18n keys for labels/status (en/zh-CN)
- Show active tab highlight and localized status text in tray panel
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Replace tray click menu with a custom panel window (#/tray)
- Panel lists sessions + port forwards and allows toggling without closing
- Add tray panel IPC (hide panel, open main window)
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Remove invalid require() of TS i18n modules from electron main
- Keep tray status text in main process to avoid module resolution issues
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Replace status dots with localized status text suffix
- Localize tray menu labels via i18n keys
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Remove submenu; click rule to start/stop directly
- Use colored status dots for sessions and port forwards
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Click tray icon to open menu (Open Main Window, sessions, port forwards, quit)
- Sync session + port forward data from renderer to main via IPC
- Handle tray actions in renderer (focus session, start/stop port forward)
- Fix tray listener unsubscribe to remove only registered handler
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* Initial plan
* feat: implement global hotkey toggle and system tray functionality
- Add globalShortcutBridge.cjs for global keyboard shortcuts and system tray
- Register/unregister global shortcuts using Electron's globalShortcut API
- Implement system tray with context menu for show/hide/quit
- Add close-to-tray behavior option
- Add new storage keys for toggle window hotkey and close-to-tray settings
- Add settings UI in System tab for configuring global hotkey
- Add i18n translations for English and Chinese
- Update preload.cjs with new IPC methods for global hotkey
- Update global.d.ts with TypeScript type definitions
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* fix: address code review feedback for global hotkey feature
- Use imported fs module instead of inline require in globalShortcutBridge
- Simplify macOS platform detection (remove mobile device patterns)
- Add warning logs for hotkey registration and tray errors
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* fix: map Control modifier to Control
* Add default tray icon fallback
* fix: bypass close-to-tray during quit
* Fix global hotkey failure handling
* fix: improve tray icon with platform-specific PNG files
- Replace SVG tray icon with PNG format for better compatibility
- Add macOS template images (tray-iconTemplate.png) for menu bar
- Add colored icons (tray-icon.png) for Windows/Linux
- Include @2x retina variants for high-DPI displays
- Remove unused mainWindow variable from globalShortcutBridge
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat: enable global hotkey and close-to-tray by default
- Default hotkey: Ctrl+` (⌃+` on macOS) - similar to VS Code terminal toggle
- Default close-to-tray: enabled
- Properly distinguish between 'never set' and 'explicitly cleared'
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix: address code review findings for global hotkey feature
P1: Fix toggleWindowVisibility to restore minimized windows
- Check isMinimized() first before checking isVisible()
- Ensures hotkey/tray toggle works when window is minimized
P2: Save window state before hiding to tray on close
- Persist window bounds before returning from close-to-tray handler
- Prevents losing window position/size on quick close after resize
P2: Surface hotkey registration failures to UI
- Add hotkeyRegistrationError state to useSettingsState
- Display error message in SettingsSystemTab instead of silently clearing
P2: Remove arbitrary renderer-provided tray icon paths
- Only use known packaged icon locations for security
- Remove iconPath parameter from setCloseToTray API
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Overhauls the README files across all languages to enhance readability and user experience.
Updates feature descriptions to better reflect the application's current capabilities, including new sections like "Why Netcatty" and "Demos". Integrates animated GIF demos for core features such as Vault views, split terminals, SFTP workflows, and personalization, offering dynamic visual explanations.
Streamlines the header by replacing image-based badges with concise text links. Replaces outdated screenshots with new, relevant images and removes a large number of old screenshot files. Updates the Electron framework version in the technology stack.
This fixes a timing issue where the useEffect for keyword highlighting
runs before the runtime is created, causing host-level rules to be missed
for fresh sessions. Now merged global and host rules are applied right
after the XTermRuntime is created in the boot() function.
Removes unused React hook imports (`useState`, `useCallback`) from SFTP components to improve code clarity.
Simplifies a `catch` block in SFTP keyboard shortcuts by removing the unused `error` variable, making the code more concise.
The cpu-features package fails to compile with newer Node.js/Electron
due to deprecated V8 APIs. Since it's an optional dependency of ssh2,
replace it with an empty package via npm overrides.
- Add inset ring border to focused SFTP pane for clear visual distinction
- Fix useSftpKeyboardShortcuts context error by passing showHiddenFiles as parameter
- Use sftpFocusStore to track which pane is currently focused
- Remove chacha20-poly1305@openssh.com from SSH cipher list as Electron's
BoringSSL (from Chromium) does not support standalone chacha20 cipher
- Upgrade Electron from 39.2.6 to 40.1.0 (Node.js 24.11.1)
- Keep AES-GCM and AES-CTR ciphers which are fully supported
The chacha20-poly1305 algorithm requires OpenSSL's chacha20 cipher which
is not available in Electron's bundled BoringSSL. This caused connection
failures with 'Unsupported algorithm' error when connecting to SSH servers.
* Show user@host:port in host selector
Replace the host selector subtitle with username, hostname, and port to
surface the actual connection target details.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Filter serial hosts from selector
Exclude serial protocol entries from SelectHostPanel results and counts to
avoid offering non-SSH targets in selection flows.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Add execCommand options to opt into keyboard-interactive auth and wire MFA only
for export-key flows, preserving non-interactive exec usage elsewhere.
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This simplifies async auth prep before opening the SSH connection and cleans up unused variables in UI and SFTP hooks.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Normalize conflict check errors in sync (#164)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Return errors when sync is attempted while locked (#165)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Set lastError when parallel uploads all fail (#167)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Block uploads on conflict check errors (#168)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Fix lastError on upload failures (#170)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Normalize conflict check errors in parallel sync (#171)
* Optimize syncAllProviders to run concurrently and encrypt once
Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.
Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.
This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
* feat: Sync port forwarding rules
- Refactor `usePortForwardingState` to use a global store pattern, ensuring state consistency across the application.
- Integrate `usePortForwardingState` into `App.tsx` to retrieve and update port forwarding rules.
- Update `useAutoSync` in `App.tsx` to include `portForwardingRules` in the sync payload and handle incoming updates via `importRules`.
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Normalize imported port forwarding statuses
* fix: correct indentation in usePortForwardingState.ts
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Normalize imported port forwarding rule status
* Stabilize port forwarding rules for auto-sync
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
- Added `CreateWorkspaceDialog` component for creating named workspaces with multiple hosts.
- Implemented `createWorkspaceWithHosts` in `useSessionState`.
- Integrated dialog into `App.tsx` and triggered from Quick Switcher.
- Updated `QuickSwitcher` logic to improve visibility of recent connections.
- Added i18n keys for the dialog.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
- Added `setDeviceName` method to `CloudSyncManager` to update state, persist to local storage, and notify listeners.
- Updated `useCloudSync` hook to expose the `setDeviceName` function from the manager.
- Ensures device name updates are correctly handled and persisted across sessions.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Refactored the host filtering logic in `useManagedSourceSync` to index hosts by `managedSourceId` before iterating through sources. This reduces the complexity from O(N*M) to O(N+M), where N is the number of sources and M is the number of hosts.
Benchmarks showed a ~74x speedup (from ~600ms to ~8ms) with 500 sources and 25,000 hosts.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
* perf: make window state saving async to avoid blocking main thread
- Convert `saveWindowState` to use `fs.promises.writeFile`
- Keep `saveWindowStateSync` for use in `close` handler
- Update `scheduleSaveState` to use async version
- Reduces blocking time from ~0.38ms to ~0.10ms per write
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Serialize async window state saves
* fix: avoid async window state overwrite on close
* fix: guard queued window state saves on close
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
- Refactor `writeKeyToDisk` and `ensureKeyDir` in `electron/main.cjs` to use `fs.promises` instead of synchronous `fs` methods.
- This prevents blocking the main thread during file I/O operations, improving application responsiveness.
- Added error handling with try/catch blocks to ensure safety.
- Verified performance improvement with a benchmark script (deleted before commit).
- Verified code quality with `npm run lint`.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Reduces the complexity of `ensureRemoteDirInternal` from O(N) to O(1) for the common case where the directory already exists.
- Adds a check for the full path at the beginning of the function.
- If the directory exists, it returns immediately.
- If not, it falls back to the existing recursive check/creation logic.
Benchmarks showed a reduction from ~8 calls to 1 call for a deep existing directory structure.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Refactored synchronous file operations in SSH key discovery to use `fs.promises` and `Promise.all`, preventing main thread blocking during connection initialization. Updated all bridge modules to handle asynchronous key retrieval.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Replaced the hardcoded '1.0.0' version string in CloudSyncManager.ts with the version from package.json.
Enabled resolveJsonModule in tsconfig.json to support JSON imports.
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
- Expose addExternalUpload and updateExternalUpload methods from useSftpState
- Add download task to transfer queue when starting stream download
- Update progress during download with transferred bytes, total, and speed
- Update task status on completion, failure, or cancellation
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* feat: stream-based SFTP download for large files
- Add showSaveDialog API for native file save dialog
- Modify handleDownload to use streaming transfer for remote files
- Show save dialog first, then stream directly to disk
- Avoid loading entire file into memory
- Fallback to memory-based download for local files or when streaming unavailable
This fixes the issue where downloading large files would cause high memory
usage as the entire file was loaded into memory before saving.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* feat: stream-based download for SftpView
- Add getSftpIdForConnection to useSftpState for accessing SFTP session IDs
- Modify handleDownloadFileForSide in useSftpViewFileOps to use streaming
- Pass showSaveDialog, startStreamTransfer to SftpView hook chain
- For remote SFTP files: show save dialog then stream directly to disk
- For local files: fallback to memory-based download
This extends the stream download optimization to SftpView (dual-pane browser),
not just SFTPModal.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: improve download task handling and cancellation
- Add per-task cancellation for downloads via onCancelTask prop
- Add i18n translation keys for download status messages
- Prevent duplicate error toasts with errorHandled flag
- Add bridge capability check (result === undefined)
- Make direction field required in TransferTask interface
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
---------
Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Track the previous initialEntriesToUpload reference and reset the
upload trigger flag when a new array is provided. This fixes the issue
where subsequent drag-to-upload operations would be ignored if the
SFTP modal remained open after the first upload completed.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* Initial plan
* Add drag-and-drop functionality for terminal
- Local terminal: Insert absolute file paths when files are dropped
- Remote terminal: Open SFTP modal and trigger file upload
- Add visual feedback with drag overlay
- Support both files and folders
- Add translations for English and Chinese
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Remove unintended package-lock.json changes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* Improve folder path handling in drag-and-drop
- Extract folder path from nested files when folder is dropped
- Show folder path once instead of individual file paths
- Handle both Windows and Unix path separators
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
* fix: address code review feedback for drag-and-drop
- Replace fixed 500ms delay with proper connection state check
(wait for files to load instead of arbitrary timeout)
- Add duplicate upload prevention with ref flag
- Simplify redundant ternary (dropEffect was always 'copy')
- Improve folder path extraction logic for better reliability
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: open SFTP at current directory when dropping files
Get the current working directory from terminal session before opening
SFTP modal for file uploads, matching the behavior of the SFTP toolbar button.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: preserve directory structure when uploading folders via drag-drop
- Pass DropEntry[] instead of File[] to preserve relativePath info
- Add handleUploadEntries function that uses uploadEntriesDirect
- This maintains folder structure when uploading directories to remote
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: support drag-drop upload to empty remote directories
Use loading state transition detection instead of files.length check
to determine when SFTP connection is ready. This fixes the issue where
drag-drop uploads to empty directories would silently fail because
files.length was always 0.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: preserve empty directories in terminal drop uploads
Pass full dropEntries array including directory markers to SFTP upload
instead of filtering to only file entries. This ensures empty folders
are created on the remote side via uploadEntriesDirect which uses
isDirectory entries to call ensureDirectory.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* fix: remove unused handleUploadMultiple import
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
* refactor: improve drag-drop code readability
- Extract path extraction logic into extractRootPathsFromDropEntries function
- Add comment explaining flushSync usage for state synchronization
- Remove redundant dropEntries.length check (already checked earlier)
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
The onScroll event may not fire for all scroll methods (e.g., mouse wheel).
Add onRender listener to detect viewport position changes and trigger
decoration refresh when scrolling stops.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Telnet connections store their port in host.telnetPort, not host.port.
Refactored getProtocolInfo to return the correct port for each protocol.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Mosh sessions use protocol: "ssh" with moshEnabled: true, so check
moshEnabled first before falling back to host.protocol.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Remove hostname === "localhost" check to avoid incorrectly treating
SSH connections to localhost as local terminal connections.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Reuse component-level isLocalConnection/isSerialConnection in useEffect
- Add i18n support for protocol labels (en/zh-CN)
- Use correct default port per protocol (SSH: 22, Telnet: 23, Mosh: 22)
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Local and serial connections no longer show connection dialog during connecting phase
- Connection dialog now displays correct protocol label based on host.protocol
(SSH, Telnet, Mosh, Local Shell, Serial) instead of hardcoded "SSH"
- Removed unnecessary timeout/progress UI for local terminal connections
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Use FolderUp icon for folder upload in context menu (matches toolbar)
- Auto-close encoding popover when an option is selected
- Add trailing newline to uploadService.ts
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Replace wide dropdown encoding selector with a compact icon button (Languages)
that opens a popover menu. Also add tooltips to navigation buttons (up, home,
refresh).
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Replace text+icon buttons with icon-only buttons and tooltips for Upload,
Upload Folder, New Folder, and New File actions. Uses more distinctive
icons (FolderPlus, FilePlus) and adds a new Tooltip component.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
The folder compression transfer setting is SFTP-specific functionality,
so it makes more sense to place it alongside other SFTP settings like
double-click behavior, auto-sync, and show hidden files.
- Move setting from SettingsTerminalTab to SettingsFileAssociationsTab
- Add new i18n keys under settings.sftp.compressedUpload namespace
- Remove unused settings.terminal.uploadDownload translations
- Update SettingsPage props accordingly
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Remove verbose console.log statements from upload components
- Add maximum timeout cap (10 min) for extraction to prevent hangs
- Fix cleanup race condition by checking connection state
- Prefix unused speed parameter with underscore
- Fix duplicate return statement and unnecessary hook dependencies
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Replace 3-option setting (ask/enabled/disabled) with boolean toggle
- Remove CompressedUploadDialog component - no more user prompts
- Auto-detect tar availability and silently fallback to regular upload
- Apply compressed upload setting to drag-and-drop uploads
- Fix upload progress updates for compressed uploads via callback
- Add i18n for upload phase labels (compressing/uploading/extracting)
- Fix empty folder handling - fallback to regular mkdir
- Preserve error message on failed upload tasks for UI display
- Fix fallback logic to only re-upload failed folders, not successful ones
- Handle cancellation during all phases (compression/transfer/extraction)
- Fix filename display to use explicit phase markers instead of substring match
- Fix Toggle onChange prop for settings to work correctly
- Use DropEntry.relativePath for correct drag-drop folder paths
- Dynamic extraction timeout based on archive size (60s base + 30s per 10MB)
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add transfer cancellation logic to cancelCompression function
- Cancel the associated transfer using transferId pattern `compress-{compressionId}`
- Check for transferBridge availability before attempting cancellation
- Add error handling and logging for transfer cancellation failures
- Ensures cleanup of both compression process and related file transfer operations
- Extract standalone file entries separately from folder entries during compressed uploads
- Add logic to upload standalone files using regular upload after compressed folders complete
- Combine results from both compressed folder uploads and standalone file uploads
- Ensure all files are uploaded correctly when mixed with compressed folder uploads
- This allows proper handling of mixed file and folder uploads in a single operation
Add clearAndRemoveSources function that clears multiple SSH config files
in parallel but removes all sources in a single atomic update. This
prevents race conditions where concurrent removals could re-add sources
that were already deleted.
When deleting a group path with multiple managed sources, the batch
removal is now used instead of Promise.all with individual removals.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Match blocks were being dropped because they have no host patterns,
causing flush() to treat them as fully-managed blocks. Now explicitly
track Match blocks and always preserve them since we don't manage them.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Pass managedSources prop through SelectHostPanel and all components that
use it (SnippetsManager, PortForwarding, KeychainManager) so hosts created
in these contexts can properly receive managedSourceId when placed in a
managed group.
This ensures hosts added from any panel will sync back to managed SSH
config files.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When generating a unique managed group name, now check against:
- Existing managed sources
- Custom groups
- Existing host groups
This prevents accidentally reusing an existing group name which could
merge unrelated hosts into the managed group.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When multiple managed sources sync concurrently via Promise.all, each
sync was independently updating the managedSources array, causing race
conditions where one source's lastSyncedAt would be overwritten.
Now syncManagedSource returns success status, and lastSyncedAt updates
are batched in a single update after all syncs complete.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Add managedSources to vaultViewAreEqual so managed source changes
(import, unmanage, rename) trigger proper re-renders and update
managed badges/actions in the UI.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Non-SSH hosts (telnet, etc.) in managed groups should keep their labels
unchanged since they cannot be synced to SSH config. The label space
sanitization now checks canBeManaged before modifying the label.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
The "Unmanage" action now only removes the source association without
modifying the SSH config file. This prevents data loss when users want
to stop syncing but keep their host entries in the file.
Use onUnmanageSource instead of onClearAndRemoveManagedSource for the
unmanage flow. The clearAndRemove variant is still available for cases
where file cleanup is explicitly desired (e.g., deleting a managed group).
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Preserve top-level comments and blank lines when merging SSH config
by tracking preamble content before the first Host/Match block
- Validate file path availability before enabling managed sync; show
error if getPathForFile and file.path are both unavailable instead
of falling back to bare filename which would sync to wrong location
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When a Host line contains multiple patterns (e.g., "Host prod1 prod2")
and only some are managed, now only the managed patterns are removed
while non-managed patterns are preserved with the block's directives.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Keep preserved SSH config content outside managed block markers to
prevent data loss when first bringing existing config under management
- Always bracket IPv6 addresses in ProxyJump values regardless of port,
as OpenSSH requires brackets to disambiguate colon separators
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When creating a managed source from an existing SSH config file without
Netcatty markers, the code was appending the managed block without removing
original Host entries. This left duplicate Host blocks, and since OpenSSH
uses the first matching block, edits via Netcatty wouldn't take effect.
Now uses mergeWithExistingSshConfig to filter out existing Host blocks
that match managed hostnames before wrapping with markers.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Use onClearAndRemoveManagedSource to write an empty managed block
before removing the source, preventing stale entries in the SSH
config file after unmanaging a group.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
IPv6 addresses like 2001:db8::1 need brackets when appending a port,
otherwise SSH cannot parse the address correctly (colons are ambiguous).
Now outputs [2001:db8::1]:2222 instead of 2001:db8::1:2222.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Generate unique managed group names by adding suffix when conflicts exist
- Only strip spaces from label based on target group, not form.managedSourceId
- Check protocol when determining if label spaces should be stripped
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Filter duplicates on managed import and convert existing hosts to managed
- Trigger initial sync when prevHosts is empty but managed sources exist
- Update previousHostsRef even when no managed sources to maintain baseline
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Replace Input component with Textarea for startup command field
- Update className to use min-h-[80px] with font-mono and text-sm styling
- Add rows={3} prop to Textarea for better multi-line command support
- Import Textarea component from ui/textarea module
- Improve UX for entering longer or multi-line startup commands
When a managed host references a jump host outside its managed source,
changes to that external jump host now trigger a sync. This ensures
ProxyJump entries stay up-to-date when jump hosts are edited.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Sanitize Host aliases and ProxyJump references by removing spaces
- Only use label as ProxyJump alias if jump host is in managed hosts
- Fall back to hostname for jump hosts outside managed config
This ensures ProxyJump directives reference valid, resolvable hosts.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
ProxyJump changes now trigger sync since hostChain.hostIds is compared
alongside other host fields like hostname, port, username, etc.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Non-SSH protocol hosts (telnet, serial, local) are now correctly
excluded from managed source assignment since SSH config files only
support the SSH protocol.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add escapeShellArg() utility function to safely wrap arguments in single quotes
- Escape targetDir and archivePath in extractRemoteArchive() command construction
- Escape targetPath in cleanup command for ._* files removal
- Escape remoteArchivePath in archive cleanup command
- Replace double-quoted arguments with properly escaped single-quoted arguments throughout shell commands
- Prevents potential shell injection vulnerabilities when paths contain special characters or malicious input
When syncing managed hosts to SSH config files, properly serialize the
hostChain to ProxyJump directive instead of just adding a comment.
- Add serializeJumpHost() to format jump host as [user@]host[:port]
- Add buildProxyJumpValue() to convert hostChain.hostIds to ProxyJump
- Pass allHosts to serializer for looking up jump host details
- Supports multiple jump hosts (comma-separated)
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When deleting a managed group, clear the managed block in the SSH config
file before removing the source. This prevents stale host entries from
remaining in the file after the group is deleted.
- Add clearAndRemoveSource function to useManagedSourceSync
- Pass callback to VaultView and call it before removing managed sources
- Ensures SSH config files stay in sync when managed groups are deleted
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Declare tempArchivePath in outer scope for proper cleanup access in error handlers
- Add path separator normalization to handle both forward and backslash separators
- Improve folder path extraction logic to correctly identify the target folder path
- Add fallback pattern matching for both Unix and Windows path separators
- Preserve original path separator style when reconstructing folder paths
- Enhance robustness of path parsing for cross-platform file uploads
- Add activeCompressionIds Set to track ongoing compression operations
- Implement addActiveCompression() and removeActiveCompression() methods for lifecycle management
- Update cancel() method to iterate through active compression IDs and call cancelCompressedUpload()
- Enhance getActiveTransferIds() to include compression IDs alongside file transfer IDs
- Clear activeCompressionIds in reset() method to ensure clean state
- Register compression ID with controller before starting folder compression
- Add cancellation checks before and during compression progress updates
- Remove compression ID from tracking on completion or error
- Distinguish between cancellation and error states in result handling
- Improve logging to separately track file transfer IDs and compression IDs
- Enables proper cancellation of compressed uploads when user cancels the operation
- Fix regex pattern escaping in trailing slash removal (use forward slash instead of escaped forward slash)
- Declare taskId outside try block to ensure it's accessible in catch block for proper error handling
- Update onTaskFailed callback to pass taskId instead of folderName for consistency with task tracking
- Add guard condition to only call onTaskFailed when taskId exists (task was successfully created)
- Prevents undefined taskId from being passed to error callbacks and improves error state management
- Refactor folder path calculation to handle nested directory structures correctly
- Remove the filename-based extraction approach in favor of relativePath-based logic
- Add fallback mechanisms to handle edge cases where relativePath doesn't match localFilePath
- Implement folder name-based path detection as secondary fallback strategy
- Preserve original logic as last resort for single file scenarios
- Fix issue where deeply nested folders were not correctly identified during compression
When multiple managed sources match a group path (nested managed groups),
select the one with the longest groupName (deepest/most specific match)
instead of the first match. This ensures hosts are assigned to the
correct managed source and sync to the right SSH config file.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add try-catch wrapper around uploadFoldersCompressed to handle compression failures gracefully
- Implement fallback to regular upload when compressed upload is not supported
- Check for failed folders in compressed results and trigger full fallback if needed
- Return error indicator from uploadFoldersCompressed instead of attempting inline fallback
- Improve error logging to distinguish between compression support issues and other failures
- Ensure all entries are uploaded via regular upload path when compression is unavailable
- This prevents upload failures when the server doesn't support compressed folder uploads
- Add CompressedUploadDialog component to let users choose between compressed and regular transfer methods
- Implement compressUploadService for handling folder compression and extraction on the server
- Add compressUploadBridge to expose compression functionality to the renderer process
- Add sftpUseCompressedUpload setting with three modes: ask, enabled, disabled
- Add new upload progress states: compressing, extracting, scanning, completed
- Add i18n translations for upload dialog and settings in English and Chinese
- Update SFTP modal to support compressed upload workflow with progress tracking
- Add storage key for persisting compressed upload preference across sessions
- Significantly reduces transfer time for folders by using tar compression when available on server
Use a ref to access the latest managedSources when updating lastSyncedAt
after sync completes. This prevents overwriting concurrent changes made
while a sync was in flight (e.g., user unmanaging a source or importing
a new managed file).
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When deleting a parent group, also remove all managed sources whose
groupName is under that path (not just exact matches). This prevents
stale managed entries from remaining after parent group deletion.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
When HostDetailsPanel is used in contexts that don't pass managedSources
(e.g., SelectHostPanel), preserve the existing managedSourceId instead of
clearing it. This ensures hosts created/edited in managed groups retain
their sync relationship.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Remove config file containing real SSH hosts (security)
- When deleting a subgroup under a managed group, keep managedSourceId
so hosts remain managed and continue syncing to SSH config
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Use marker blocks to preserve existing SSH config directives (Match, Include, Host *, etc.)
- Only replace content between BEGIN/END NETCATTY MANAGED markers
- Update managedSources.groupName when groups are renamed or moved
- Prevents data loss for users with custom SSH config entries
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add protocol to change detection to trigger sync when protocol changes
- Sanitize labels (remove spaces) when moving hosts to managed groups via drag/drop
- Prevent duplicate managed imports by checking if file is already managed
- Add i18n keys for already managed file warning
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add pending sync tracking to process host changes that occur during sync
- Strip spaces from labels when host is/will be in a managed source group
- Remove unused FileSymlink import
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add i18n translations for "Upload folder" in English and Chinese locales
- Add folderInputRef to track folder input element in SFTPModal component
- Implement handleFolderSelect handler for processing folder uploads
- Add "Upload folder" button to SftpModalHeader with FolderUp icon
- Add hidden file input with webkitdirectory attribute for folder selection
- Update SftpModalFileList context menu to include folder upload option
- Pass folderInputRef and folder handlers through component hierarchy
- Enable users to upload entire folder structures via SFTP modal
- Fix managed host sync: add group field to change detection
- Auto-set managedSourceId when moving host to managed group
- Add 'managed' badge to managed hosts in VaultView
- Fix file path resolution for managed sources using webUtils
- Add managedSources prop to HostDetailsPanel for proper sync
- Restrict spaces in label for managed hosts
- Reorder HostDetailsPanel sections: General > Address > Port & Credentials
- Add debug logging for managed source sync troubleshooting
Update editingSnippet.package when the package being edited is renamed
or is nested under a renamed package. This prevents the stale package
path from being persisted when the user saves their edits after a rename.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Add context menu option to rename snippet packages with a modal dialog.
Includes validation for empty names, duplicate names (case-insensitive),
and invalid characters (only letters, numbers, hyphens, underscores allowed).
When a package is renamed, all nested packages and snippets are updated
to reflect the new path.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Strip trailing slashes before saving package paths to ensure consistent
path handling across the UI. This prevents issues where 'foo/' would not
match 'foo' in the package browser.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Address Codex review: when selecting a parent path from the package
dropdown that was generated from existing child packages (e.g., /foo
derived from /foo/bar), the path is now added to the packages array.
This prevents orphaned snippets when the child package is deleted.
Co-Authored-By: Claude <noreply@anthropic.com>
- Detect if selected package path is absolute (starts with '/')
- Reconstruct breadcrumb paths with leading slash when applicable
- Prevent loss of absolute path context when navigating package hierarchy
- Ensures consistent path handling between package selection and breadcrumb display
- Strip leading slash from snippet names when creating paths inside packages
- Preserve leading slash for snippets created at root level
- Prevent double slashes in constructed package paths (e.g., "package//snippet")
- Improve path handling consistency between root and nested snippet creation
- Update validation regex to allow hyphens anywhere in package names
- Simplify regex pattern from `^\/?\w+([\w/-]*\w+)*\/?$` to `^\/?([\w-]+(\/[\w-]+)*)\/?$`
- Update HTML input pattern attribute to match validation logic
- Improve comment clarity to reflect hyphen handling in package names
- Ensures consistent validation between JavaScript regex and HTML5 pattern attribute
- Separate absolute paths (starting with /) from relative paths for clearer processing
- Process relative and absolute paths independently with distinct handling logic
- Add type annotations to filter callbacks for better type safety
- Simplify path matching logic by removing redundant checks for both slash variants
- Display absolute paths with "/" prefix to distinguish them from relative paths
- Improve code readability by extracting path processing into separate sections
- Maintain backward compatibility while fixing edge cases in package hierarchy
## Overview
This PR addresses multiple critical bugs in code package creation and display functionality, and includes validation enhancements and performance optimizations to improve overall stability and user experience.
## Fixed Bugs
- Fixed issue where package paths starting with a slash (e.g., /name/xx/xx) failed to display
- Fixed package count showing only direct code snippets instead of including nested package content
- Fixed path conflict bug in movePackage() caused by improper string replacement
- Fixed dropdown selector displaying only full paths (missing parent path options)
- Added package name validation to block invalid characters and duplicate package names
- Optimized package deletion performance by only saving actually modified code snippets
- Added support for creating packages in /100/200/300 format, with dropdown selector showing all hierarchical paths
## Key Improvements
* displayedPackages: Correctly handle slash-leading paths and accurately calculate nested package counts
* createPackage: Added regex validation and duplicate check, support paths starting with a slash
* movePackage: Replaced replace() with substring() to avoid substring-based path conflicts
* packageOptions: Automatically generate all parent path options, sorted by depth and alphabetical order
* deletePackage: Improved performance by only persisting actually modified code snippets (instead of full dataset)
- Display folders above ungrouped hosts in tree view
- Change host connection from double-click to single-click
- Sanitize host before connecting to handle whitespace in hostname
- Guard optional host.tags to prevent crash on legacy data
- Show telnet-specific credentials (telnetUsername/telnetPort) for telnet hosts
- Remove unused groupTree variable and prefix unused moveHostToGroup param
Co-Authored-By: Claude <noreply@anthropic.com>
- Use filtered treeViewHosts instead of all hosts when building tree view group tree
- Ensure grouped hosts respect search queries and tag filters
- Reorder useMemo dependencies to fix circular dependency issue
- Now tree view filtering behavior is consistent with grid and list views
Fixes issue where grouped hosts would still appear even when they didn't
match active search or tag filters, breaking the expected filtering UX.
- Add tree view mode alongside existing grid and list views
- Implement hierarchical display of hosts organized by groups
- Add expand/collapse all controls with Chinese translations
- Support all sorting modes (A-Z, Z-A, newest, oldest) in tree view
- Persist expand/collapse state across view switches and app restarts
- Hide Groups section in tree view to avoid duplication
- Display ungrouped hosts at root level instead of "General" group
- Add missing delete group dialog with proper translations
- Maintain full functionality: search, filtering, drag-drop, context menus
Technical changes:
- Create HostTreeView component with TreeNode recursive structure
- Add useTreeExpandedState hook for persistent state management
- Extend ViewMode type to include "tree" option
- Add sortMode prop to enable dynamic sorting in tree structure
- Separate group tree logic for tree view vs other view modes
- Add comprehensive English and Chinese translations
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.
Previously, options._unlockedEncryptedKeys was only used for the
final target host, so jump hosts requiring encrypted default keys
would still fail even after successful passphrase entry.
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
- Add PassphraseModal component for interactive passphrase input
- Add passphraseHandler bridge to manage passphrase requests/responses
- Add sshAuthHelper for centralized SSH key decryption with passphrase support
- Update sshBridge, sftpBridge, and portForwardingBridge to use new auth helper
- Add passphrase-related IPC channels in preload and type definitions
- Add i18n translations for passphrase modal UI (en/zh-CN)
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
Previously, jump host connections (connectThroughChain) did not try
default SSH keys from ~/.ssh/ when no explicit auth was configured.
This caused authentication failures when using jump hosts without
manually specifying SSH keys.
Changes:
- Add ssh-agent support for jump host connections
- Try all default SSH keys (id_ed25519, id_ecdsa, id_rsa) for jump hosts
- Use dynamic authHandler to try each key in sequence
- Match the same fallback behavior as direct connections
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.
Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.
Changed to async exec with promisify to allow non-blocking execution.
Fixes#134
Co-Authored-By: Claude <noreply@anthropic.com>
When servers require multi-step authentication (e.g., password + MFA, or
publickey + keyboard-interactive), the previous implementation did not
properly handle the partialSuccess flag from ssh2's authHandler callback.
This caused MFA-only servers to fail connection because keyboard-interactive
was not triggered after the initial auth method succeeded with partialSuccess.
Changes:
- Add partialSuccess handling to try server-requested auth methods
- Track attempted methods to avoid re-trying failed or already-used methods
- Cache the first successful method (not the last) for multi-step flows
to ensure correct auth order on subsequent connections
Co-Authored-By: Claude <noreply@anthropic.com>
Agent may be auto-configured via SSH_AUTH_SOCK rather than explicit
user choice. On servers with PubkeyAuthentication disabled or low
MaxAuthTries, the agent attempt could exhaust auth tries before the
valid password is attempted.
New order: user key -> password -> agent -> default key -> keyboard-interactive
This follows ssh2's default order (None -> Password -> Private Key -> Agent)
more closely and prioritizes explicit user configuration.
Co-Authored-By: Claude <noreply@anthropic.com>
When connectOpts.agent is a NetcattyAgent (for certificate auth),
it contains _meta with privateKey/passphrase. Logging the full object
would leak credentials to log files. Now only logs a safe identifier.
Co-Authored-By: Claude <noreply@anthropic.com>
ssh2's simple auth handler (array mode) only enables publickey auth
when connectOpts.privateKey is set. Without setting the key, the
"publickey" entry in auth order would be silently skipped.
Co-Authored-By: Claude <noreply@anthropic.com>
The dynamic authHandler for fallback authentication was missing the
"agent" type, which broke agentForwarding functionality. This fix:
- Adds "agent" to the default availableMethods list
- Updates methodName mapping to treat "agent" as "publickey" (since
agent-based auth uses publickey verification under the hood)
- Adds handler case for agent type that returns "agent" string
- Checks both methodName and method.type for availability
Co-Authored-By: Claude <noreply@anthropic.com>
ssh2 requires a prompt function when returning an object for
keyboard-interactive auth. Without it, the method is skipped.
Return the string "keyboard-interactive" instead, which lets ssh2
use its default handling and properly trigger the keyboard-interactive
event for 2FA/MFA prompts.
Co-Authored-By: Claude <noreply@anthropic.com>
When no explicit auth method is configured, the default key was being
promoted to connectOpts.privateKey and then added again as publickey-default.
This caused the same key to be attempted twice, wasting auth slots.
Now track when default key is used as primary to skip redundant fallback.
Co-Authored-By: Claude <noreply@anthropic.com>
- Always search for default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa)
as fallback authentication method
- Add dynamic authHandler that tries multiple auth methods in sequence:
user key -> password -> default system key -> keyboard-interactive
- Cache successful auth methods per host to speed up subsequent connections
- Clear auth cache on failure to retry all methods
- Fix password validation to only use non-empty strings
- Add detailed logging for auth flow debugging
Co-Authored-By: Claude <noreply@anthropic.com>
Previously, the keyboard shortcuts shown in the right-click context menu
were hardcoded and did not reflect user's custom keybindings from settings.
Changes:
- Pass keyBindings prop from Terminal to TerminalContextMenu
- Dynamically look up shortcuts from user's configured keybindings
- Format shortcuts with spaces between keys for better readability
- Handle 'Disabled' shortcuts by hiding the shortcut hint
Co-Authored-By: Claude <noreply@anthropic.com>
Add the ability to duplicate an SSH session by right-clicking on a tab
and selecting "Copy Tab". This creates a new session with the same
connection parameters (host, port, protocol, etc.).
Co-Authored-By: Claude <noreply@anthropic.com>
The previous approach tried to reconstruct paths for nested files using
filePathMap keyed by f.name (base file names), but for folder drops
rootName is the folder name which doesn't exist in the map.
Now we call getPathForFile directly on each result.file, which should
work for all files in Electron. The filePathMap reconstruction is kept
as a fallback.
This ensures large files inside dropped folders use stream transfers
instead of falling back to arrayBuffer() which causes OOM.
Co-Authored-By: Claude <noreply@anthropic.com>
When fs.createWriteStream is destroyed, it emits 'close' but not 'finish'.
Added close event handlers for downloadWithStreams and local-to-local
copy to properly resolve the promise when cancelled.
The 'finished' flag in cleanup() ensures we don't call resolve/reject twice
when both finish and close fire during normal completion.
Co-Authored-By: Claude <noreply@anthropic.com>
When streams are destroyed during cancellation, the close/finish event
handler was not calling cleanup if transfer.cancelled was true. This left
the promise pending forever, causing the UI to stay stuck in "uploading".
Now we call cleanup(new Error('Transfer cancelled')) when the stream
closes/finishes and the transfer was cancelled.
Co-Authored-By: Claude <noreply@anthropic.com>
- Replace memory-based file uploads with stream transfers for large files
- Add uploadWithStreams and downloadWithStreams functions in transferBridge
- Fix cancel transfer by properly destroying streams instead of throwing
errors in callbacks (which corrupted SSH connection)
- Fix upload button not triggering upload by copying FileList before
clearing input (clearing input also clears FileList reference)
- Export getPathForFile utility for obtaining local file paths
- Add startStreamTransfer and cancelTransfer bridge methods
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:24:58 +08:00
598 changed files with 114955 additions and 16427 deletions
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git push:*)",
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
apple_silicon:`[](${baseUrl}/${files.mac.arm64})`,
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.
## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
---
## Aside Panel Design System
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
> 🚀 **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.
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
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.
- **Font customization** — JetBrains Mono, Fira Code, and more
- **i18n support** — English, 简体中文, and more
---
<a name="screenshots"></a>
# Screenshots
<a name="host-management"></a>
## Host Management
<a name="main-window"></a>
## Main Window
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.

<a name="terminal"></a>
## Terminal

Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.

**Split Windows**
<a name="split-terminals"></a>
## Split Terminals
**Broadcast Mode**
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.

**Performance Info & Customization**
Monitor your connection health and customize every aspect of your terminal.
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
| Type | Direction | Use Case | Example |
|------|-----------|----------|--------|
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
| Provider | Best For | Setup Complexity |
|----------|----------|------------------|
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
| **Google Drive** | Personal use, large storage | ⭐ Easy |
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
**What syncs:**
- ✅ Hosts & connection settings
- ✅ SSH keys & certificates
- ✅ Identities & credentials
- ✅ Snippets & scripts
- ✅ Custom groups & tags
- ✅ Port forwarding rules
- ✅ Application preferences

<a name="themes--customization"></a>
## Themes & Customization
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.


---
@@ -268,10 +274,9 @@ Netcatty automatically detects and displays OS icons for connected hosts:
@@ -287,11 +292,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> Or right-click the app → Open → Click "Open" in the dialog.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Prerequisites
- Node.js 18+ and npm
@@ -355,7 +356,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
| Category | Technology |
|----------|------------|
| Framework | Electron 39 |
| Framework | Electron 40 |
| Frontend | React 19, TypeScript |
| Build Tool | Vite 7 |
| Terminal | xterm.js 5 |
@@ -387,7 +388,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
thrownewError(`Proxy credentials for jump host "${jumpHost.label||jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.