Resolve conflicts in terminal backend, Terminal, and TerminalView to keep ZMODEM drag-drop alongside serial YMODEM receive and terminal selection AI settings from main.
Co-authored-by: Cursor <cursoragent@cursor.com>
Syncs the terminal selection Add to Conversation preference with AI settings so cloud sync, local backups, restores, and settings-only auto-sync detect the value.
Follow-up for #1436.
Adds a Settings > AI switch to hide the automatic Add to Conversation button on terminal selection while keeping the context menu action available.
Closes#1397
- Remove outdated SFTP upload message and replace it with ZMODEM-specific messages in English, Russian, and Chinese locales.
- Add a new function to handle ZMODEM drag-and-drop uploads in the terminal backend.
- Update terminal components to support ZMODEM drag-and-drop functionality.
- Enhance error handling for file uploads and provide user feedback for no files to upload.
- Introduce tests to verify ZMODEM upload behavior and fallback to SFTP for network devices.
Hide Netcatty-managed Docker and tmux terminal launch commands from command history.
Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
Show an X control beside the host sidebar search input so users can reset the filter without manually deleting text.
Co-authored-by: Cursor <cursoragent@cursor.com>
Expose a quick Activity icon on SSH Linux sessions so users can jump directly to the system manager side panel from the terminal header.
Co-authored-by: Cursor <cursoragent@cursor.com>
Use the shared bg-muted selected state so history host/global tabs match the system manager tab styling.
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
Record commands as users type across sessions with dedup and AI-noise
filtering, and browse them alongside per-host remote history. Refine the
scope switcher UI and route fullscreen layout recovery through the terminal
backend hook. Closes#1253.
Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes#1382
Deleting the active AI chat session left panelViewByScope pointing at a removed session, causing the side panel UI to blank. Sync panel view cleanup with session deletion, stop in-flight streams on delete, and return to draft with the deleted session's agent preserved.
* feat(terminal): add remote command history side panel
Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes#1381.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(history): expand command detail inline below selected row
Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(history): filter Netcatty AI PTY commands from remote history
Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(history): tighten detail strip and add run action
Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(history): address review findings before merge
Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Allow the quick switch shortcut to close the panel when pressed again,
including while the search input is focused, so users are not limited to
clicking outside to dismiss it.
Closes#1355
Co-authored-by: Cursor <cursoragent@cursor.com>
Restore resize when macOS App Nap or GPU eviction leaves xterm stale after
switching away, by refitting on visibility/focus/fullscreen and fixing the
ResizeObserver race with async xterm boot.
Co-authored-by: Cursor <cursoragent@cursor.com>
Cold-start local terminals on Linux could cache Powerline icon tofu when the
shell prompt arrived before Symbols Nerd Font Mono finished loading. Preload
the icon fallback at the active cell size and clear the xterm atlas so
already-drawn prompt glyphs re-rasterize (fixes#1363).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(et): support server stats for EternalTerminal sessions
- Generalize the Mosh stats companion into reusable connection helpers
- Open a companion SSH connection so the host-info bar works for ET sessions
- Fall back to execOnEtSession for jumped ET sessions without a direct connection
- Forward host-key and algorithm options to the ET backend for companion parity
- Close the ET stats companion on session close, cleanup, and PTY exit
* fix(et): harden stats exec host-key trust and cleanup
Enforce StrictHostKeyChecking=yes for background ET stats/distro probes
instead of accept-new, merge vault known_hosts for parity with ssh2
companions, and wrap companion connection teardown in try/catch.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes#1375 by letting Cmd/Ctrl+[1...9] target only work tabs when enabled, while keeping the existing Terminus-style default.
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the compose bar inside the terminal workspace so SFTP side panels stay full height, refit xterm when the bar toggles, and remeasure split-pane geometry when side panels open or close.
Co-authored-by: Cursor <cursoragent@cursor.com>
Restore xterm keyboard focus after top-level tab changes so macOS users
can type immediately without an extra click (discussion #1339).
Co-authored-by: Cursor <cursoragent@cursor.com>
Add a persistent quick-snippet strip, draggable height, and terminal-matched UI to the compose bar, addressing quick-command and resize requests from community discussions.
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(settings): reduce Mac settings window input lag (#1347)
Debounce custom CSS commits, memoize heavy tabs, and replace Radix ScrollArea
with native scrolling so typing and navigation stay responsive on macOS.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): flush debounced textarea on unmount
Avoid losing custom CSS edits when the settings window closes before the
debounce timer fires.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(terminal): resolve bold font weight without document.fonts.check false positives
Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes#1364).
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop unused primaryFontFamily from terminal effects context
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ui): improve host tree inline group rename interactions
Cancel rename when clicking another tree row and prevent parent drag from blocking text selection in the rename input.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ui): block group toggle keyboard while inline renaming
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ui): cancel host inline rename when clicking other tree rows
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Root cause: FPM-generated .pacman packages copy icons directly to
/usr/share/icons/hicolor/*/apps/netcatty.png, bypassing Arch's alpm
hooks that normally run gtk-update-icon-cache. Without a refreshed
cache, KDE Plasma cannot resolve Icon=netcatty and falls back to a
generic document icon in the app menu.
Fix:
- Copy electron-builder's default after-install template to
scripts/linux/after-install.tpl, append gtk-update-icon-cache call
- Create scripts/linux/after-remove.tpl with the same cache refresh
- Wire into pacman.afterInstall/pacman.afterRemove
(NOT linux.afterInstall — the schema places these under target-level
options like PacmanOptions/DebOptions, not LinuxConfiguration)
- Add test in electron-builder-config.test.cjs
The command is idempotent on systems without gtk-update-icon-cache
(hash guard) and uses || true to never break package installation.
Align window controls with utility icons, extend hover to the full title bar height, restore red close-button hover, flush close to the right edge, and use neutral gray hover for top-bar utility buttons.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.
Co-authored-by: Cursor <cursoragent@cursor.com>
Windows + Node >= 24: spawning .cmd files with shell=false causes EINVAL.
Claude Code v2.1.169 ships as native binary (no cli.js), npm global install
creates only claude.cmd. Netcatty detected claude.cmd but spawned it with
shell:false -> EINVAL.
Changes:
- resolveWindowsShimToNativeExe: new function that reads .cmd/.bat shims
and resolves to the real .exe using "%~dp0\...\*.exe" pattern matching
- prepareCommandForSpawn: tries native exe resolution first, falls back
to shell:true wrapping
- resolveClaudeCodeExecutableForSdk: when cli.js not found, looks for
bin/claude.exe native binary
- 3 new tests for shim resolution and spawn spec
- Codex CLI unaffected (already handles native exe resolution)
Test: 38/38 shellUtils tests pass, npx tsc --noEmit clean
Fix terminal drag-and-drop uploads so they target the active terminal cwd and avoid fallback home/login-shell cwd when the active cwd cannot be confirmed.
- event.preventDefault() must be called synchronously before the
async IPC call, otherwise the browser processes the default paste
action before we can intercept it
- When clipboard has no files (or on error), fall back to text paste
via pasteTextIntoTerminal since the default action was already
prevented
When pasting (Ctrl+V / right-click paste) in a local terminal,
if the clipboard contains files, insert their paths instead of
doing nothing.
- New hook useTerminalFilePaste: capture-phase paste listener
on terminal container, reads clipboard files via Electron bridge,
formats paths (spaces quoted, deduped), writes to session
- Updated useTerminalContextActions: right-click paste checks
clipboard files first, falls back to text paste
- New terminalHelpers.extractRootPathsFromClipboardFiles
- Tests: 9 unit tests for path extraction logic
- Verified via headless Chromium integration test (15 tests)
- Build: npm run build ✅, npm test ✅ (1899 pass)
* fix(linux): restore multi-size hicolor icons for Ubuntu launchers (#1340)
PR #816 set linux.icon to a single 1024px PNG, which regressed the #274
fix and left only hicolor/1024x1024 on .deb installs. Drop the override
so electron-builder uses build/icons again, regenerate those PNGs from the
tight-crop icon-win source, and add a helper script plus a config test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(linux): set linux.icon to icons dir for proper multi-size hicolor icons (#1340)
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vault): add duplicate host action to tree view context menu
Wire the existing onDuplicateHost handler into vault host tree menus so
tree view matches grid/list duplicate behavior. Fixes#1329.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vault): switch to hosts section when duplicating from terminal tree
Ensure the host details panel is visible after duplicate is triggered
from the terminal host tree sidebar.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ai): cap Catty agent request payload to prevent HTTP 413
Long-running chats accumulated full terminal tool outputs in SDK history
while token-based compaction only triggered near the model context window,
so nginx gateways could reject oversized JSON bodies before the model saw them.
Add a byte-budget pass that compresses verbose output, tail-preserving
truncation, and a safe sliding window before each Catty agent turn.
Fixes#1323
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ai): compaction was using unfiltered sdkMessages, fix didAdjust and emergency loop
* fix(ai): compact and retry oversized Catty requests
* fix(ai): preserve current input while guarding 413 retries
* fix(ai): avoid false 413 detection and fit oversized current input
* fix(ai): pair replayed tool results chronologically
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(terminal): smooth layout drags and faster tab switching
Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(terminal): keep side panels alive and guard session attach races
Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(terminal): reduce tab switch jank
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid accidentally persisting built-in editor as the default for all
extensionless files when double-clicking binaries without an extension.
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce shared SettingCard and SettingsSection primitives so AI, SFTP,
system, and terminal tabs use the same white-card + row control pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(sftp): add follow terminal directory mode for sidebar (#1266)
Add a toolbar toggle that keeps the side-panel SFTP browser synced with the linked SSH terminal cwd, inspired by MobaXterm's follow-folder behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(sftp): probe pwd after commands when follow mode lacks OSC 7
Add a deferred getSessionPwd fallback after terminal commands when follow-terminal-cwd is enabled and the shell did not report OSC 7, and fix settings sync hook dependencies.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(sftp): repair follow toggle UI and backend cwd sync fallback
Fix SftpPaneView memo skipping follow prop updates, probe fresh pwd when OSC 7 is missing, and broaden linked terminal session resolution for sidebar follow mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(sftp): harden follow sync after review findings
Reuse shouldFollowTerminalCwdNavigate in production path, re-read connection
after async cwd probe, skip redundant navigate on toggle, and only probe pwd
when the SFTP side panel is open.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Require bug/feature reports via issue forms with automated format checks, while accepting legacy Bug: titles from older app builds.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add issue #1293 screenshot-exact cases for sudo/telnet autofill and
include English password in the handleOutput fast-path bypass.
Co-authored-by: Cursor <cursoragent@cursor.com>
Kylin Professional's sudo prompt doesn't include the [sudo] tag and
doesn't end with a colon. The existing regex patterns required either
[sudo] or a trailing colon, causing the autofill hint to never fire.
Changes:
- Make trailing colon optional in SUDO_PROMPT_PATTERN and
EXPLICIT_SUDO_PROMPT_PATTERN (terminalSudoAutofill.ts)
- Update fast path to not skip output containing Chinese password
keywords (密码/口令) that lack a colon
- Make trailing colon/angle-bracket optional in telnet username and
password prompt patterns (telnetAutoLogin.cjs)
- Also relax LAST_LOGIN_PATTERN for consistency
- Add Kylin-style test cases for both sudo and telnet auto-login
Expose whole-window transparency via setOpacity with settings and a top-bar quick control, persisting across restarts and syncing across windows.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: align top bar right-side icon buttons (#1298)
Unify AI/sync/theme/settings buttons to h-7 in one row aligned with tabs.
SyncStatusButton was h-8 and settings lived in a separate container, causing
misalignment. Preserve Windows spacing before window controls (mr-2).
Fixes#1298
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: align window controls with utility icons in top bar
Merge window controls into the same row as utility buttons, match h-7 height, add left margin separation, and restore rectangular gray hover for all three buttons.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Expose data-section selectors for SFTP/side panel, split panes, and resizers
so custom CSS can target the correct regions. Clarify docs that
terminal-workspace-sidebar is focus-mode only.
Fixes#1301
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ai): infer MIME type from file extension for YAML and other code files
When uploading YAML files (and other code/text files) via Electron,
file.type is often empty, causing the system to default to
'application/octet-stream'. AI providers reject this media type
with 'functionality not supported'.
Fix by inferring the correct MIME type from the file extension
when file.type is empty. Includes mappings for YAML, JSON, TOML,
shell scripts, and 50+ common code/text file extensions.
Fixes#1287
* fix(ai): use text/plain for all code/text files to ensure provider compatibility
Change all non-standard MIME types (text/x-*, application/x-*) to
text/plain for maximum provider compatibility. Anthropic and other
providers reject non-standard MIME types like application/x-yaml
with 'UnsupportedFunctionalityError'.
Changes:
- All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain
- Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain
- yaml/yml → text/plain (was application/x-yaml)
- dockerfile → text/plain (was text/x-dockerfile)
- Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
* fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286)
Two bugs in prompt detection for Chinese-locale users:
1. telnetAutoLogin: USERNAME_PROMPT_PATTERN, PASSWORD_PROMPT_PATTERN,
and LAST_LOGIN_PATTERN only matched half-width colon ':' or '>'.
Chinese-locale telnet prompts use full-width colon ':' (U+FF1A),
e.g. '登录:', '密码:'. Changed [:'>] to [::'|] in all three
patterns to accept both colon variants.
2. terminalSudoAutofill: EXPLICIT_SUDO_PROMPT_PATTERN required
'[sudo]' (closing bracket immediately after 'sudo'), but Chinese
sudo prompts use '[sudo: authenticate] 密码:' format where sudo
is followed by colon. Changed \[sudo\] to \[sudo[^\]]*\] to
match any '[sudo...]' variant, making the explicit (no-arm-needed)
hint detection work for Chinese locale.
Fixes#1286
* fix: restore OSC stripping pattern broken in previous commit
The regex negated character class [^\x07]* was truncated to just \x07,
breaking OSC sequence stripping (e.g. window title changes embedded in
terminal output). Restore the original negated class so stripTerminalControl-
Sequences continues to remove OSC title sequences before prompt detection.
This was caught by Codex review of PR #1288.
Sudo hints were flaky for manually typed commands: arming depends on
recognizing the submitted line as a command (recordedCommand), which is
unreliable while echo round-trips over SSH — so the hint sometimes didn't
fire for "sudo -i" / "sudo -s".
Explicit "[sudo] …" prompts are sudo-specific, so hint on them without
requiring an arm — reliable regardless of command recording. Bare
"Password:" still needs the arm window to avoid noise on unrelated prompts
(ssh, mysql). Filling still requires explicit Enter, so showing the hint
without arming stays safe. Added a colon fast-path so bulk output skips the
detection regex.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sudo autofill only read host.password and never resolved a host's reference
to a Keychain identity (host.identityId). When the account password lived in
a referenced identity, the autofill got nothing — while SSH login worked
because it goes through resolveHostAuth, which resolves the identity.
Add domain resolveHostAutofillPassword (same resolveHostAuth resolution:
identity.password ?? host.password, honoring savePassword and dropping
undecryptable placeholders) and use it as the terminal autofill password
source. Login and autofill now share one resolution path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework sudo password autofill from auto-fill to a Tabby-style hint + Enter to confirm. When a sudo command is armed and a password prompt appears, show a dimmed inline hint instead of sending the password; Enter pastes the saved password and submits, any other key dismisses it.
Confirmation removes the credential-leak class (nothing is sent without the user pressing Enter at a visible hint), so detection is relaxed to a broad match (Ubuntu/PAM bare "Password:", "[sudo] password for…", localized prompts) and the per-host toggle is removed — always available when the host has a saved password.
Safety guards:
- don't arm when the hint can't render (no overlay) so Enter isn't silently intercepted;
- swallow Escape/Backspace so the byte never reaches the no-echo prompt;
- clear the pending hint once output moves past the prompt (sudo timeout/failure/returns to shell) so a later Enter can't leak the password to the shell.
Implementation ~140 lines; full suite green; manually verified on a real Linux host.
Replace the command-rewriting scheme (inject sudo -p marker, sanitize the echo, Ctrl-U retype) with passive observation: arm a short window on a sudo command and fill the password when a real sudo prompt appears.
- Fixes the Ubuntu/PAM no-fill, the cursor jump below the prompt, and the typed-vs-autocomplete discrepancy from #1281.
- Detection requires the [sudo] tag, or a whole-line bare "Password:" / "密码:"; prefixed prompts (mysql -p "Enter password:", ssh "x@h's password:", psql "Password for user x:") are rejected so the sudo password can't leak to a child program when sudo's creds are warm.
- Disarms when a non-sudo command follows, so a stale window can't fill a later prompt.
Implementation: 322 -> ~140 lines.
The dedupe in startSession.cjs runs under `with(ctx)`, where ctx is wired
with sshBridge.cjs's own local findDefaultPrivateKey /
findAllDefaultPrivateKeys — not the sshAuthHelper.cjs copies. The
characterization test targeted the helper's exports, which the connect
path never calls (sshAuthHelper.findDefaultPrivateKey has no production
consumers at all), so it gave false confidence and the in-code comment
pointed at the wrong test.
Expose the local pair as _findDefaultPrivateKey / _findAllDefaultPrivateKeys
(matching the existing _-prefixed test-export convention) and retarget the
test at them, so it actually guards the path the optimization depends on.
Behavior is unchanged; the two local functions are verified equivalent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each terminal that loads the WebGL addon holds a live WebGL context for
its whole lifetime, and all session panes stay mounted (hidden ones
off-screen). Batch connect therefore created a WebGL context per host up
front, contending for the GPU on the main thread — also the root of the
"garbled / 花屏" corruption in #1049/#1063. Defer WebGL creation for panes
that mount hidden (background tabs of a batch) and upgrade them on first
visibility via an idempotent ensureWebglRenderer(); a hidden pane renders
through xterm's DOM renderer until shown. Visible panes (single connect,
the active tab) keep creating WebGL immediately — unchanged behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch-connecting N hosts called onConnect() in a synchronous forEach, so
all N terminals mounted in one React commit and each createXTermRuntime()
(which spins up a live WebGL context) ran back-to-back on the main
thread, freezing the UI until the whole batch finished (~2-3s per host,
linear). Spread the connects across frames via a small injectable-scheduler
helper: the first host still connects synchronously so its tab appears
immediately, the rest are deferred one step apart so no two heavy mounts
land on the same frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every SSH connect ran two separate ~/.ssh scans back-to-back:
findDefaultPrivateKey() then findAllDefaultPrivateKeys(). They share
identical filter/sort/encrypted-skip logic, so the first scan's result
is exactly findAllDefaultPrivateKeys()[0]. Derive the preferred default
key from the full list (scanned once) instead, and kick that single scan
off before the identity-file / inline-key preparation so the filesystem
work overlaps the key prep instead of running serially after it.
Behavior is unchanged: auth order and fallback keys are identical. The
equivalence the dedupe relies on is pinned by a new characterization
test against a faked ~/.ssh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
When sourceSessionId is requested but the bridge falls back to a
fresh connection, the pane remains non-interactive (loading=true)
with stale cached files shown — acceptable trade-off vs always
showing the distracting spinner for near-instant reused connections.
The flag was persisting forever, suppressing loading UI for all
subsequent navigations and refreshes. Clear it once status
becomes 'connected' so only the initial reuse skips the spinner.
When SFTP reuses an existing terminal SSH connection, the
connection is near-instant so the loading spinner and overlay
are distracting noise. Added a reusedConnection flag to
SftpConnection and skip the loading UI when set.
Changes:
- SftpConnection model: +reusedConnection boolean
- useSftpConnections: set reusedConnection when sourceSessionId exists
- SftpPaneToolbar: skip animate-spin for reused connections
- SftpPaneFileList: skip loading overlay for reused connections
- SftpPaneTreeView: skip loading overlay for reused connections
Follow-up to #1254
When activeSessionId arrives after activeHost (e.g. focus
update in workspace), the effect must re-run to pass the
session ID to connect() — otherwise SFTP falls back to a
fresh SSH connection.
When opening the SFTP side panel for a host that already has an
active terminal session, reuse the terminal's authenticated SSH
connection instead of creating a new one.
Changes:
- TerminalLayer: compute activeTerminalSessionIdForSftp, matching
hostname/port/username against the active session
- TerminalLayerView: pass activeSessionId to SftpSidePanel
- SftpSidePanel: accept activeSessionId, pass to connect()
- useSftpConnections: pass sourceSessionId to bridge.openSftp()
- sftpBridge/openConnection: try to find and reuse terminal session's
SSH connection via findReusableSession, fall back to fresh connection
- sftpBridge: wire up acquireConnectionRef/releaseConnectionRef for
shared connection lifecycle
Only SSH (non-mosh/et/local) connected sessions are reused. Falls
back gracefully to a fresh connection on any reuse failure.
When a host is in a group with telnetPort configured, switching
protocol should not override the port to 23 — let the group default
take effect. Also handles undefined port during switch (fallback to
protocol default when no group defaults exist).
When a host inherits its Telnet port from a group config
(groupDefaults.telnetPort), the save handler should leave
port as undefined rather than materialising 23.
Added hasGroupTelnetPortDefault parameter to
resolvePrimaryProtocolSavePort to preserve inheritance.
When a user toggles the primary protocol to Telnet, the port field
previously stayed at 22 (SSH default). Now it auto-switches:
- SSH → Telnet: port 22 → 23
- Telnet → SSH: port 23 → 22
Custom ports are preserved.
Also fixes the save handler to fall back to 23 for telnet when
no explicit port is set, matching the telnet protocol default.
Closes#1251
* fix(ai): make SDK deps optional, degrade gracefully when missing
- Move @anthropic-ai/claude-agent-sdk and @github/copilot-sdk
from dependencies to optionalDependencies so npm install does
not fail when they are unavailable
- claudeDriver.listClaudeModels: catch import error, return [] silently
- copilotDriver.listCopilotModels: catch import error, return [] silently
- sdkStreamHandlers: downgrade log from console.error to console.debug
The renderer already falls back to curated model presets when
list-models returns [], so no functional change.
* fixup: honor queryFn before SDK import; regenerate lockfile with optional markers
* fixup: guard runTurn against missing SDK modules
runClaudeTurn and runCopilotTurn now catch dynamic import errors
and emit a user-friendly error message instead of crashing with
a raw module-not-found error.
* feat(sftp): add Open with system default for native Windows file association (fixes#1236)
Adds a new 'Open with system default' option to the SFTP context menu
that uses Electron's shell.openPath() to invoke the native OS file opening
mechanism. On Windows, this uses ShellExecute, which works with UWP/WinUI
apps (Photos, Paint, etc.) whose executable paths change with updates.
The existing 'Open with...' (browse for executable) is preserved.
Closes#1236
* fix(sftp): add file watch support to openWithSystemDefault for SFTP auto-sync
P2: The new default-app open flow was not starting a file watch
when SFTP auto-sync was enabled, so edits saved in the system
default app were not uploaded back to the remote host.
Mirrors the downloadToTempAndOpen behavior — accepts an optional
{ enableWatch } parameter and starts a file watch when set.
* fix(sftp): mark transfer as failed when system default open fails
P2: When shell.openPath fails for a remote file, the transfer queue
still shows success because downloadToTemp had already completed the
temp download. Now updates externalTransferId to 'failed' before
throwing, matching downloadToTempAndOpen's behavior.
Add full EternalTerminal (ET) protocol support alongside the existing SSH, Mosh, Telnet, and Serial protocols. ET automatically reconnects when the network drops or the user's IP changes, making it ideal for mobile and unreliable networks.
* fix: route Cmd+W through existing tab close flow
Keep the original tab-close behavior intact, and close the main or settings window only when there is no active closable tab to handle.
* fix: fall back to closing non-listener windows on Cmd+W
Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
TerminalPane forwards sshDebugLogEnabled to its child at render (L717) but
never destructured it from props, so rendering threw
"ReferenceError: sshDebugLogEnabled is not defined". The prop already exists
in TerminalPaneProps and the memo comparator; only the destructuring was
missing. tsc flags it (TS2304) but the build does not gate on tsc. Introduced
in 85f486e6.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215)
macOS in-place auto-update downloaded and unpacked the new version but
never installed it: the app appeared to close, ShipIt never ran, and no
restart happened. A full uninstall + reinstall did not help; only a
manual DMG replace worked.
Root cause is a code-level coordination bug, not the release pipeline.
The published mac zips are correctly Developer-ID signed, notarized, and
stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and
latest-mac.yml is well-formed — so Squirrel.Mac signature validation
passes. The failure is that quitAndInstall() drives app.quit() while two
normal-quit behaviors keep the process alive:
1. the main-window close handler hides to tray when close-to-tray is
enabled (it only closes when isQuitting is true), and
2. the before-quit dirty-editor guard preventDefault()s the quit for a
5s renderer round-trip.
Either keeps the parent process running, so Squirrel.Mac's ShipIt helper
— which waits on the parent PID to die before swapping the bundle —
lands in launchd "pending spawn / on-demand-only" limbo and the service
is removed without installing. This matches the reporter's diagnosis
exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only).
Fix: before quitAndInstall fires app.quit(), mark the app as quitting
for an update via windowManager.setQuittingForUpdate(true). That sets
isQuitting (bypassing close-to-tray) and the before-quit handler now
returns early when isQuittingForUpdate() is true (skipping the
dirty-editor round-trip), so the process exits cleanly and ShipIt can
run. The same fix also covers the latent Windows NSIS case where
close-to-tray would block an in-place update.
If the install never actually quits the app (quitAndInstall throws, or
returns without app.quit() on a Squirrel follow-up error / stale
download), the quitting-for-update flags are rolled back — synchronously
on throw, and via a short unref'd watchdog otherwise — so the app does
not get stuck permanently bypassing close-to-tray and the quit guard.
Tests: unit tests for the new windowManager flags and for the install
handler — ordering (setQuittingForUpdate before quitAndInstall), tray
cleanup still runs, no-op when the updater fails to load, rollback on
synchronous throw, and watchdog rollback when the app never quits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): keep dirty-editor guard during update install
setQuittingForUpdate only bypasses close-to-tray (so the window actually
closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the
unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would
silently lose edits. If the user cancels to save, the quit aborts and
autoUpdateBridge's watchdog clears the quitting-for-update flags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): clear update-quit state when the quit is cancelled
When the user clicks "Restart Now" with a dirty editor open, the before-quit
guard cancels the quit (settle "stay"). The update path had already called
setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray
for the install), so without clearing it the app stays in a quitting state —
close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s
watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): check for unsaved editors before quitAndInstall (#1215)
The previous fix bypassed close-to-tray so the app process actually exits and
Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit
dirty-editor guard as the unsaved-work safety net. But on macOS that net has a
hole: quitAndInstall() closes the window FIRST and only then fires before-quit.
Once setQuittingForUpdate(true) lets the main window truly close (instead of
hiding to tray), the before-quit guard can run after the window is already gone
— isReachableByUser is false, so it commits the quit and silently drops unsaved
SFTP edits.
Fix: move the dirty-editor check to the moment the user clicks "Restart Now",
in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the
window and renderer are still alive:
- dirty -> abort the install (don't set the quitting flags, don't
quitAndInstall) and broadcast netcatty:update:needs-save so the
renderer prompts the user to save and retry.
- clean -> proceed with the existing flow (commit-to-quit, tray cleanup,
quitAndInstall, watchdog).
- no reachable main window / crashed renderer -> install directly (no user to
ask), matching the before-quit fail-open path.
The before-quit dirty guard is kept as defense-in-depth: if the window is still
reachable it re-checks (clean, since we just verified), and if it's already gone
it lets the quit through — which is now safe because the install handler already
confirmed there were no unsaved editors.
The request/reply/timeout round-trip is extracted into a shared helper,
electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install
handler and main.cjs's before-quit guard use one implementation. main.cjs's
before-quit is refactored onto it (behavior preserved: sender-filtered reply,
fail-open timeout, and the setQuittingForUpdate(false) rollback when the user
cancels to save).
The needs-save notice is BROADCAST to every window, not just the queried main
window: "Restart to Update" can be clicked from the Settings window, which would
otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the
subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx
(main window) and SettingsPage (settings window) — pass an onNeedsSave callback
that shows an actionable toast ("save your editors, then click Restart Now
again") in en / zh-CN / ru.
Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On
macOS quitAndInstall() can return while Squirrel is still pulling the downloaded
ZIP from the local update server before it closes the windows; on a large/slow
update that can exceed 10s. Clearing isQuitting that early would let the eventual
native quit hit a non-quitting close-to-tray handler and strand the install
again — the exact #1215 failure. The longer window only fires when the app is
realistically stuck, at the cost of close-to-tray staying bypassed a little
longer in the rare genuine-failure case.
Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or
crashed webContents / send-throws / no-ipcMain paths); install handler pre-check
(dirty -> no quitAndInstall + needs-save broadcast to all windows; clean ->
quitAndInstall runs; no main window -> installs without asking). Existing
install-handler tests updated for the now-async handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).
Make input symmetric with output:
- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
charset normalization plus encodeTerminalInput(), which encodes a keystroke
string with the same iconv charset (returning the string untouched for UTF-8
and for unset/unknown encodings so the transport's native serialization and
the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
encodeTerminalInput(session.encoding) before writing to the SSH stream,
telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
output decoder everywhere the decoder is configured:
* Telnet/serial already stored session.encoding; writeToSession now reads
it for input too.
* SSH mirrors session.encoding wherever it sets sessionEncodings: the
GB-variant pre-seed at session start and the runtime setEncoding handler.
The pre-seed stays gated to GB variants to match the renderer's two-value
encoding state, so behavior for other/arbitrary charsets is unchanged —
the renderer still pushes the effective encoding via setEncoding on
attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
input.
Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.
Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
GoogleDriveAdapter refreshed its access token only in memory: the rotated
tokens were never written back, so the next launch loaded a stale access
token and the user was forced to reconnect. This is the same defect #1208
fixed for OneDrive, which GoogleDriveAdapter never received because it did
not expose setOnTokensRefreshed — making the shared
attachTokenRefreshPersistence a no-op for Google.
Add setOnTokensRefreshed to GoogleDriveAdapter and route both refresh
points (setTokens / ensureValidToken) through a refreshTokens helper that
stores the rotated tokens in memory and notifies the persistence callback,
so attachTokenRefreshPersistence now encrypts and persists them.
Google's refresh response usually omits refresh_token (it does not rotate
on every refresh), so refreshTokens carries the previous refresh token
forward when the response lacks one — otherwise the persisted connection
would become unrefreshable.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Auto-refresh OneDrive sync token and prompt reconnect on dead refresh token
OneDrive cloud sync (#1189) broke for users on 1.1.20 and only recovered
after disconnecting and re-authorizing. Two gaps caused this:
1. Refreshed tokens were never persisted. When OneDriveAdapter silently
refreshed the access token mid-session, the rotated tokens lived only in
the adapter's in-memory state — CloudSyncManager never read them back or
saved them. Microsoft consumer refresh tokens rotate on every refresh and
invalidate the previous one, so the next app launch loaded a stale,
rotated-out refresh token. Eventually that stored token was dead and sync
failed, forcing a manual reconnect.
Fix: OneDriveAdapter now exposes setOnTokensRefreshed(); the manager wires
it so every silent refresh writes the rotated tokens back into provider
state and encrypted storage (attachTokenRefreshPersistence /
persistRefreshedProviderTokens), keeping the stored refresh token current.
2. A genuinely dead refresh token surfaced as a raw, generic error with no
guidance. The bridge now detects invalid_grant / interaction_required /
consent_required / login_required on refresh and tags the error with a
stable marker. OneDriveAdapter normalizes these to
OneDriveReauthRequiredError; the marker survives IPC and error re-wrapping
so the condition stays detectable, and the UI strips it to show a clean
"OneDrive session expired, please reconnect." message.
Shared marker + detection/clean helpers live in domain/sync.ts so the bridge,
adapter, and UI use one source of truth. Scope is limited to OneDrive; other
providers (Gist/iCloud/Google/WebDAV/S3) are untouched.
Tests: OneDriveAdapter refresh-persistence + reauth detection, manager
token-persistence wiring, and bridge invalid_grant tagging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Drop OneDrive to a reconnect state when its refresh token is dead
codex review: detecting a dead refresh token was not enough. A provider in
`error` state that still holds tokens stays "ready for sync"
(isProviderReadyForSync), and syncAllProviders resets such providers back to
`connected` and retries — so auto-sync kept hammering the dead refresh token
and the user never got a stable reconnect prompt.
Now, when a sync/download error indicates OneDrive reauth is required, the
manager clears the stale tokens and tears down the cached adapter
(handleProviderReauthRequired), leaving the provider in an error state with no
credentials. With no tokens, isProviderReadyForSync returns false so auto-sync
stops retrying, and the card shows a clean "please reconnect" message with a
Connect button. The account is preserved for display; the error message is
stripped of the internal marker.
Wired into the syncToProvider / uploadToProvider / downloadFromProvider error
paths. Added tests for the clear-on-reauth behavior and provider/error scoping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Clear OneDrive reauth during inspection paths; include service tests in npm test
codex review round 2:
P2 — A dead OneDrive refresh token can also surface during startup remote
inspection and the syncAllProviders preflight conflict check, both of which run
through inspectProviderRemoteState. That path swallowed the error into an
{error} tuple without clearing the stale tokens, so the provider stayed
retryable. Wired the reauth handler into inspectProviderRemoteState's catch,
covering sync preflight, syncAll preflight, and startup inspection in one place.
Made the handler idempotent so the operation's own catch can also call it
without re-saving.
P3 — New tests live under infrastructure/services/, which npm test's globs did
not cover (the pre-existing syncAllStorageMethods.test.ts was also uncovered).
Added infrastructure/services/*.test.ts and infrastructure/services/*/*.test.ts
to the test script.
Full suite: 1400 tests, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: drop unmatched services test glob from npm test
The npm test script listed both infrastructure/services/*.test.ts and the
nested infrastructure/services/*/*.test.ts. No .test.ts files live directly
under infrastructure/services/ (the OneDrive adapter and cloudSync tests are
in adapters/ and cloudSync/ subdirs), so the flat glob never matched.
Under a default POSIX shell (sh/bash without nullglob), an unmatched glob is
passed through literally, so node --test received the raw pattern. On Node
versions without test-runner glob support this aborts with
"Could not find '.../infrastructure/services/*.test.ts'" before any test runs,
breaking npm test.
Remove the redundant flat glob; the nested glob already covers the new
OneDrive adapter and cloudSync tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Show host info for Mosh sessions via a stats companion SSH connection
Mosh sessions run over UDP through a local mosh-client PTY and carry no
ssh2 connection (session.conn), so getServerStats could not open an exec
channel and the terminal's host-info bar (CPU/memory/disk/network) stayed
empty — unlike SSH sessions (issue #1198).
Add a best-effort, non-interactive companion SSH connection that is opened
lazily on the first stats poll for a Mosh session, reusing the credentials
the Mosh handshake already validated, and assign it to session.conn so the
existing stats path works unchanged:
- electron/bridges/sshBridge/moshStatsConnection.cjs: new helper that
builds the companion connection. It never prompts (only stored password,
parseable private key, unencrypted/stored-passphrase identity files, or
ssh-agent), shares one in-flight attempt across concurrent polls, treats
auth rejection as permanent but transient errors as retryable, and skips
host-key verification like the existing one-off execCommand path.
- sessionOps.getServerStats: establish the companion connection for Mosh
sessions that lack session.conn before running the stats command;
degrades gracefully to the existing "not connected" error otherwise.
- moshSession.swapToMoshClient: stash the handshake credentials and
algorithm settings on session.moshStatsAuth once the handshake succeeds.
- terminalBridge closeSession / cleanupAllSessions and the mosh-client exit
handler: tear down the companion connection (it has no session.stream).
- Forward legacyAlgorithms / skipEcdsaHostKey / algorithmOverrides through
the renderer's Mosh starter and the bridge type so the companion
negotiates the same algorithms the interactive session would.
Tests cover the helper (auth selection, agent fallback, dedup, permanent vs
transient failure, late-ready discard), the getServerStats integration, and
the moshStatsAuth stash + companion teardown on close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: target SSH host and settle on mid-handshake close
- moshSession: store options.hostname (the SSH endpoint) on moshStatsAuth
instead of parsed.host. A `MOSH IP` line advertises the UDP endpoint for
mosh-client, which can differ from the SSH host on NAT / multi-homed
setups; the companion is an SSH connection and must target the SSH host.
- moshStatsConnection: resolve the pending attempt from the "close" handler
when the socket drops mid-handshake without a prior "ready"/"error", so an
awaiting getServerStats call (and session.moshStatsConnPromise) cannot
hang indefinitely. Treated as transient so the next poll may retry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: keyboard-interactive password for stats companion
PAM-backed SSH servers often offer password auth only via
keyboard-interactive, not the plain "password" method. The Mosh handshake's
system ssh handles that through its PTY responder, so without it the
companion stats connection would fail auth on those hosts even with a saved
password, leaving the stats bar empty.
When a saved password is present, enable tryKeyboard and attach a
non-interactive keyboard-interactive handler that auto-fills the password
for a single password prompt (using the existing
isAutoFillablePasswordChallenge predicate) and finishes empty on
2FA/OTP/multi-prompt challenges. It auto-fills at most once so a wrong
password can't drive a retry loop, and it never shows a modal — the
companion stays fully non-interactive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: verify host key before sending a saved password
A background companion connection that auto-submits a saved password to an
unverified host could disclose it to a spoofed / MITM server (P1). Gate
password auth (plain and keyboard-interactive) behind a silent, trusted-only
host-key check against Netcatty's known-hosts store:
- moshStatsConnection: when the companion would authenticate with a
password, attach an ssh2 hostVerifier that accepts only a key already
"trusted" in known-hosts (via hostKeyVerifier.classifyHostKey) and rejects
unknown/changed keys outright — no prompt, so the password is never sent to
an unvetted host and no host-key dialog pops for a background poll.
Public-key / agent auth proves possession via a signature and discloses no
reusable secret, so it is not gated (matches the existing execCommand
precedent; the handshake already vetted the host via system ssh).
- Thread knownHosts through the renderer Mosh starter, the bridge type, and
session.moshStatsAuth.
Note: a password-auth Mosh host that Netcatty has never seen via its own SSH
path (so it is absent from Netcatty's known-hosts) will not get the stats
companion until its key is known — the safe default. Key/agent-auth hosts are
unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: transient pre-handshake polls and agent+password auth
Two functional gaps in the stats companion:
- Missing moshStatsAuth is now transient, not permanent. The renderer can
mark a Mosh session "connected" (and start polling) from the SSH
bootstrap's visible PTY output before the swap to mosh-client assigns
moshStatsAuth. Previously that first poll set moshStatsConnFailed
permanently, so the companion was never attempted after the handshake
actually completed. Now it just returns null and a later poll retries.
- A saved password no longer suppresses ssh-agent auth. A public-key host
that authenticates via the agent may still carry a stored password; the
companion now offers the agent alongside the password (ssh2 tries agent
first) instead of attempting password-only and failing permanently. An
explicit private key still suppresses the agent fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: gate password at authHandler, not whole connection
The previous fix installed a trusted-only hostVerifier whenever a password
was present. Because ssh2 verifies the host before any auth method, that
rejected the entire connection on a host absent from Netcatty's known-hosts
— blocking key/agent auth too, even though those never need to send the
password.
Move the gate from the transport to the auth layer:
- A trust-tracking hostVerifier records whether the live host key is trusted
(during the transport handshake) and then accepts the transport so
public-key / agent auth can proceed on any host.
- A function-form authHandler offers none -> agent -> publickey always, and
appends password + keyboard-interactive only when the host key is trusted.
Result: key/agent auth works on hosts Netcatty hasn't vetted, while a saved
password is still never sent to an untrusted host. Public-key / agent auth
remains ungated (no reusable secret is disclosed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: isolate Mosh stats connection from session.conn
Storing the stats companion on session.conn made it look like the session's
primary interactive SSH connection. Other bridges key off session.conn —
getSessionPwd assumes its exec channel is a sibling of the interactive shell,
and SFTP / MCP exec run over session.conn — but a Mosh session's shell lives
on the UDP mosh-client, not this background connection. After a stats poll
they could return a bogus cwd or operate over the wrong connection.
Keep the companion strictly on session.moshStatsConn:
- ensureMoshStatsConnection stores/reuses/clears only session.moshStatsConn.
- getServerStats reads session.conn || session.moshStatsConn (real SSH still
uses conn; Mosh uses the companion) and only opens one when neither exists.
- closeSession / cleanupAllSessions / the mosh-client exit handler tear down
session.moshStatsConn.
This leaves session.conn untouched for Mosh, so getSessionPwd / SFTP / MCP
exec behave exactly as before (no primary SSH connection for Mosh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: don't count pre-handshake Mosh polls as failures
useServerStats gives up after 3 consecutive failures. A Mosh session can be
marked "connected" (and start polling) from the SSH bootstrap's visible
output before swapToMoshClient stores moshStatsAuth, during which
ensureMoshStatsConnection returns null. Previously getServerStats reported
that as a normal failure, so a handshake taking ~15s (3 polls) would
permanently disable stats for the session even after credentials became
available.
Introduce a `pending` result:
- getServerStats returns { success: false, pending: true } for a Mosh session
that has no connection yet and no moshStatsAuth and hasn't permanently
failed. Once moshStatsAuth is set (or the companion permanently fails), it
reports a normal failure again.
- useServerStats treats `pending` as neutral: it does not update stats and
does not increment the consecutive-failure counter, so polling continues
until the handshake completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: verify host key for all Mosh stats companion auth
The companion installed a trust-tracking host verifier only when a saved
password was present; key/agent-only connections fell back to ssh2's
default of accepting any host key. A background, user-invisible connection
that authenticated against an unverified host could let a MITM/DNS-spoofed
host feed bogus host-info to the user and enumerate the ssh-agent's public
keys — breaking the host-key guarantee the interactive session enforces.
Attach the host verifier for every auth method and reject an unknown or
changed host key outright (never prompting; stats just stay empty). Treat
an untrusted host as a permanent failure so polling stops reconnecting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mosh): trust system known_hosts for the stats companion host-key check
The Mosh stats companion opens a background ssh2 connection and only rides
on a host whose live key is already trusted, rejecting unknown/changed keys
as a permanent failure. Trust was sourced solely from Netcatty's in-app
known-hosts snapshot (options.knownHosts).
But a Mosh session is bootstrapped by the system `ssh`, which vets and
records the host key in the user's OpenSSH known_hosts (~/.ssh/known_hosts,
etc). Netcatty's snapshot is never updated by that handshake, so a host
trusted purely via system ssh was misread as "unknown" and the companion
permanently disabled — Mosh stats never appeared unless the user manually
scanned/imported the host into Netcatty (codex P2).
Add a system-known_hosts trust source (systemKnownHosts.cjs) and consult it
in the companion verifier when the in-app snapshot does not already vouch
for the key. Matching is by the LIVE key's SHA-256 fingerprint, so trust is
granted only for the exact key the user's own OpenSSH already trusts; an
arbitrary or mismatched key is never accepted. Unknown/changed keys stay
rejected and remain a permanent failure.
The parser handles the OpenSSH known_hosts(5) format that the in-app scan
parser does not fully cover for matching: plain hosts, comma lists,
[host]:port, hashed |1|salt|HMAC-SHA1(salt,token) entries (with the
bracketed token for non-default ports, verified against ssh-keygen -H),
multiple key types, @revoked (forces NOT trusted) and @cert-authority
(skipped). Wildcard/negation patterns are deliberately not honored. Paths
mirror localFsBridge.readKnownHosts and are cross-platform (incl. Windows
%PROGRAMDATA%\ssh\known_hosts). All errors fail closed.
ssh2 ships no known_hosts parser, so this is implemented in CommonJS using
node:crypto and covered by unit tests (real ssh-keygen hashed fixtures,
revoked/cert-authority, fingerprint mismatch, fail-closed) plus companion
integration tests (system-only trust accepts; neither source trusts ->
rejected + permanent; key rotation not rescued; optional-dependency safety).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
From the local codex review of the ET jump-host changes:
- A jump host authenticated by a saved reference key had its on-disk key path
dropped: privateKey is undefined for reference keys and only
jumpHost.identityFilePaths was forwarded, so ET jump auth fell back to
defaults despite a valid key being selected. Mirror startSSH and forward the
reference key's filePath as an IdentityFile (with the same password-method
and keyId fallback guards).
- A jump host listening on a non-default ET server port was always contacted
on 2022: the bridge reads jump.etPort (--jport) but the renderer never sent
it. Forward jumpHost.etPort and add etPort to NetcattyJumpHost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review: for an ET host the connection dialog displayed host.port
(the SSH port, e.g. 22), but ET connectivity hinges on the etserver port
(host.etPort, default 2022). Showing 22 is misleading when a connection is
actually stuck on the ET port. Display etPort (falling back to 2022) for ET
hosts instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review: startEt only checked resolvedChainHosts.length, so a
configured jump chain that failed to resolve (missing/invalid host ID) would
silently fall back to a direct connection — possibly to the wrong target —
unlike startSSH, which explicitly rejects missing chain host IDs.
Add the same getMissingChainHostIds check startSSH uses, erroring early with
a clear message when a configured jump host cannot be resolved. Also base the
"at most one jump host" limit on the configured chain (host.hostChain.hostIds)
rather than only the resolved list, so a second hop whose ID fails to resolve
cannot slip past the check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses two correctness issues from the codex review of the ET protocol
support.
codex P1 #1 (Unix askpass helper): on macOS/Linux the SSH_ASKPASS helper
was the askpass .cjs itself, relying on its `#!/usr/bin/env node` shebang.
Packaged Electron builds put no `node` on the user's PATH, so ssh could not
run the helper and ET could not supply the saved password / key passphrase,
failing the connection outright. Mirror the existing Windows .cmd wrapper:
write a small /bin/sh wrapper that execs the helper through process.execPath
with ELECTRON_RUN_AS_NODE=1 (process.execPath is POSIX single-quoted to
survive spaces in an .app path).
codex P1 #2 (jump host routing): a jumped ET host only got an ssh
ProxyCommand, which fixes the SSH bootstrap but leaves ET opening its TCP
socket straight at the destination etserver — which fails whenever the
destination ET port is not directly reachable. Use ET's own
--jumphost/--jport so ET connects its socket to the jumphost's etserver and
reaches the destination over the SSH tunnel ET sets up (`ssh -J`). Per-hop
jump credentials move from the ProxyCommand into a `Host <jumphost>` ssh_config
block (OpenSSH applies command-line -o only to the final hop, so jump settings
must come from config); the destination's comma/space options are scoped under
a `Host <dest>` block with a ProxyJump so the standalone ssh used for distro
detection tunnels through the jump too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the existing mosh bundling pipeline so that release and tag
builds automatically fetch and package the EternalTerminal `et`
client alongside mosh-client.
Two more review findings on the connection-reuse path:
- Verify the source connection's endpoint matches the duplicate's
requested hostname/port/username before reusing it. A saved host edited
after the source tab connected would otherwise let the copy silently
open on the old connection and run commands on the wrong machine.
findReusableSession now takes the requested target and requires an exact
endpoint match; the session records its actual SSH endpoint at connect.
- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
synchronously (e.g. "Not connected") if the borrowed transport dropped
between findReusableSession and the shell request; without this the
up-front connection ref hold would leak. On a synchronous throw we now
drop the error listener, release the ref, and fall back to a fresh
connection.
Adds tests for endpoint mismatch and synchronous shell failure.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reference-counting races found in review:
- Reuse path read the source's connRef only inside the async conn.shell()
callback. If the source tab closed while the shell was opening,
releaseConnectionRef could drop the count to zero and end the shared
connection out from under the opening channel. Now pin the connection
(acquireConnectionRef) before issuing the async shell request and hand
the hold over to the real session once the channel opens; release it on
any failure so fallback to a fresh connection doesn't leak the count.
- closeSession read session.connRef *after* stream.close(), but closing the
channel can synchronously fire the stream "close" handler that nulls
connRef and releases the connection — so the post-close check fell into
the legacy path and ended the shared connection a second time. Snapshot
the multiplexing flag before closing the channel.
Adds a regression test covering "source closed while the copied shell is
still opening".
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading _logStreamToken back off the session map in the connection-level
close/error/timeout handlers could let a late close from an old transport
stop a newer same-sessionId log stream after a reconnect, regressing the
token guard from #916. Capture the owner channel's log stream token in the
connection closure and pass it to stopStream, matching the original
closure-scoped behavior.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.
- copySession records the source session id (reuseConnectionFromSessionId)
for connected, non-mosh SSH sessions; it is threaded down to the SSH
bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
source connection. The shell wiring is extracted into a shared
setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
module (mirrors Tabby ref/unref/destroy): the shared transport + jump
host chain are torn down only when the last channel closes, so closing
a copy — or the original while a copy is open — never kills siblings.
terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
skipped for X11 hosts (X11 is negotiated per channel).
Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2: the panel maxWidth constants used by the horizontal clamp's
totalWidth excluded each panel's padding + border (inline styles default to
content-box), so a padded detail tooltip at its 280px max could still render
wider than reserved and spill off-screen.
Set box-sizing: border-box on the shared panel style so every maxWidth is the
true outer width and totalWidth is exact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2: totalWidth and the vertical height reservation depended on the
hovered/selected item's detail panel, so moving the mouse between a
no-detail row and a detail row could change the clamped position and shift
the popup under the pointer (flicker, unreliable clicks near the edge).
Reserve detail-panel width/height from a set-level flag (any non-path row
with a description) so placement stays stable while hovering; the tooltip
itself still renders only for the active row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The terminal completion popup could spill past the window edges and ignored
the theme accent color (#1202):
- Horizontal: the left-edge clamp only reserved the main list width (400px),
so expanding a directory near the right edge pushed the cascading sub-dir
panels and the detail tooltip off-screen. Now the clamp accounts for the
full assembly width (main list + sub-dir panels + detail tooltip) and pins
to the left padding when it is wider than the viewport.
- Vertical: extracted the flip/clamp math into a pure, unit-tested
computeAutocompletePopupPlacement() so "not enough room below -> flip up
and bound the height" is verifiable. The detail tooltip is now height-
bounded and scrolls instead of overflowing for long snippet descriptions.
- Accent: the selected/hover row background and the active-row accent rail now
derive from the active terminal theme's cursor/selection colors (which track
the user's accent setting) instead of a hardcoded blue.
Adds terminalAutocompleteLayout.test.ts covering downward/upward placement,
bottom-of-viewport flip, height clamping, and horizontal clamping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top-bar AI button previously dispatched netcatty:toggle-ai-panel,
which was wired to handleOpenAI -> handleSwitchSidePanelTab('ai'). That
switch is a no-op when AI is already the open sub-panel, so the panel
could not be dismissed by clicking the button again.
Add a dedicated handleToggleAiFromTopBar that routes through a new
resolveAiSidePanelToggleIntent helper: a second click on an already-open
AI panel closes the side panel, while a click from a closed panel or a
different sub-panel switches to AI. The top-bar event listener now uses
this toggle handler. handleOpenAI stays a plain switch so the AI icon in
the side-panel rail remains idempotent like the other rail tabs.
Also remove the non-functional reminder (bell) button from the top bar;
it had no click handler and no backing feature.
Refs #1194
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alibaba Cloud Linux hosts (os-release ID="alinux") previously showed the
generic Linux icon because normalizeDistroId fell through to the catch-all
`linux` branch ('alinux'.includes('linux') is true).
- Add a dedicated `alinux` branch in normalizeDistroId, matching the
os-release ID, the legacy `aliyun` ID, and the NAME/PRETTY_NAME text
"Alibaba Cloud Linux"; place it before the generic linux fallback.
- Register `alinux` in LINUX_DISTRO_OPTIONS so it is selectable in the
manual distro override and classified as linux-like.
- Add the brand SVG (public/distro/alinux.svg) plus logo/color mappings
in DistroAvatar (brand color #FF6A00).
- Add the localized label in en / ru / zh-CN.
- Cover normalizeDistroId's alinux cases (ID, legacy aliyun, PRETTY_NAME)
with unit tests, including a regression guard against the generic linux
fallback.
Icon source: simple-icons "Alibaba Cloud" mark (CC0-1.0, public domain),
matching the existing distro icon set already sourced from simple-icons.
Closes#1200
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the implementation status of EternalTerminal integration
across all layers (domain, electron, application, components, i18n)
with testing notes and known limitations.
Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
Terminal: protocol label, proxy-unsupported warning, multi-jump
limitation notice. Vault: ET settings section title, etPort and
etTerminalPath field labels with descriptions.
Resolve 'et' protocol in tray panel and host connection handlers with
priority over mosh. Propagate etEnabled through session factories and
useSessionState. Expose etAvailable guard and startEtSession wrapper
in useTerminalBackend.
Add startEtSession to the preload API surface, routing through the
netcatty:et:start IPC channel. Define the startEtSession options type
in netcatty-bridge-session.d.ts with ET-specific parameters (etPort,
terminalPath, jumpHosts, etc.).
Add etSession.cjs: full ET session lifecycle including SSH bootstrap with
host-key verification, etclient PTY spawning, temp directory management,
and external auth artifact cleanup. Wire the session API into
terminalBridge.cjs with IPC handler registration. Add bundledEtClient
resolver that locates the platform-specific et binary in both packaged
and dev environments. Include unit tests for both etSession and
bundledEtClient.
Add 'et' to HostProtocol union type. Add etEnabled, etPort, and
etTerminalPath fields to Host and GroupConfig interfaces. Update
vaultImport type guards to exclude 'et' from importable protocols.
Register ET fields in groupConfig inheritable keys.
Download and package the et binaries produced by build-et-binaries:
- resolve-et-bin-release picks the latest et-bin-* release; fetch-et-binaries
downloads the platform client into resources/et/, verifying SHA256SUMS.
- et-extra-resources emits the electron-builder extraResources entry only
when the binary is on disk, so pack still works without a bundled et.
- electron-builder.config.cjs wires et into the mac/win/linux bundles;
package.json adds the fetch:et scripts.
Build the EternalTerminal `et` client in CI the way mosh-client is:
- build-et-binaries.yml builds et for linux x64/arm64, macOS universal
and windows x64, uploads artifacts, and optionally publishes them to a
dedicated binary repository on manual workflow_dispatch.
- scripts/build-et/{linux,macos,windows} compile et from upstream.
- .gitignore excludes the fetched binaries; resources/et/README.md
documents source provenance.
* feat(vault): show/hide toggle for the host Telnet password
Issue #1099 (part 2): the host-level Telnet password field was a bare type="password" with no way to reveal it. Add an eye toggle matching the SSH password field and the group-level Telnet password.
Refs #1099
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(vault): reset Telnet password reveal when the edited host changes
HostDetailsPanel is reused across hosts without remounting, so the new showTelnetPassword state has to be cleared in the initialData effect alongside showPassword — otherwise revealing one host's Telnet password leaves the next host's shown unmasked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Issue #1099: a Telnet host shows the protocol picker on every connect. Connect resolution is now explicit, and the picker only appears when it's genuinely a choice:
- Telnet set as default (protocol = telnet) -> connect Telnet (single-click and batch)
- Telnet enabled but not the default -> show the picker (unchanged)
- Mosh enabled -> connect Mosh
- otherwise -> connect SSH
A "Connect with Telnet by default" switch in the Telnet card sets the host's primary protocol. SSH+Mosh hosts now connect Mosh directly instead of prompting every time. Addresses part 1 of #1099.
Refs #1099
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The multi-select toolbar already supported batch delete; add a "Connect" button next to it so connecting many hosts no longer has to be done one by one. Connects each selected host in list order with its configured protocol (multi-protocol hosts use their default rather than prompting per host).
Closes#870
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The delete and overwrite confirmation dialogs are opened from a context menu, whose focus-return could leave focus outside the dialog, so pressing Enter did nothing. Focus the confirm button when each dialog opens via onOpenAutoFocus so Enter activates it. Esc and Tab+Enter behavior is unchanged.
Fixes#1150
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ssh): repair mangled PEM private keys before parsing
A valid PEM key whose framing was damaged in transit — newlines
collapsed to spaces, turned into literal "\n", or lines indented —
fails ssh2's parser with "Unsupported key format" even though the key
material is intact. This commonly happens when a key is copy/pasted
through a field or app that strips line breaks. (follow-up to #1139)
When parsing fails, rebuild clean PEM framing from the BEGIN/END markers
(which survive newline loss) and the base64 body, then retry through the
existing parse and PKCS#8 conversion paths. The body is preserved
byte-for-byte and a repaired key is only used if it re-validates, so
this can never produce a different or invalid key. Encrypted legacy PEM
(Proc-Type/DEK-Info) and truncated keys are left untouched.
Refs #1139
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ssh): detect encryption on mangled OpenSSH keys
A mangled encrypted OpenSSH key (line breaks flattened to literal "\n")
was not recognized as encrypted: the literal escapes corrupt the base64
decode used to read the cipher name, so isKeyEncrypted() returned false
and preparePrivateKeyForAuth routed the key to the unencrypted branch
with no passphrase prompt — and the repaired candidate was discarded
because it can't parse without one.
Repair the PEM framing before reading the OpenSSH cipher name, so such
keys are detected as encrypted and reach the passphrase prompt, where
normalizePrivateKeyForSsh2(key, passphrase) already repairs and
validates them. Addresses Codex review feedback on #1147.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ssh2's key parser only accepts OpenSSH, legacy PKCS#1/SEC1 and PuTTY
keys, so PKCS#8 keys (-----BEGIN PRIVATE KEY----- / BEGIN ENCRYPTED
PRIVATE KEY) fail with "Cannot parse privateKey: Unsupported key
format" even though they are valid and work in other clients such as
Termius. (#1139)
Add privateKeyNormalizer: when ssh2 can't parse a key but it is PKCS#8,
read it with Node's crypto and re-export RSA->PKCS#1 / EC->SEC1, the
legacy PEM forms ssh2 understands. Encrypted PKCS#8 is decrypted with
the passphrase first. Ed25519 (and other) PKCS#8 keys, which have no
legacy PEM form, now surface a clear "convert with ssh-keygen" message
instead of ssh2's opaque error.
Wired into sshAuthHelper's preparePrivateKeyForAuth and
loadIdentityFileForAuth, so SSH, SFTP and port-forwarding all benefit.
Also fixes a latent bug where a correct passphrase on an encrypted
PKCS#8 key was rejected and re-prompted indefinitely.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- TopTabs: the Vaults root tab no longer paints an active background fill when
selected (text/icon still brighten). Clear the imperatively-set hover fill on
click so it can't get stuck when active bg stays transparent.
- VaultView: when the host side panel is open, drive the grid column count from
the container width as a fixed N columns instead of viewport-based grid-cols-*
(which can't see the narrowed area) or auto-fit+1fr (which stretched a lone
card across the whole row). Fills the row with no trailing gap and keeps a
single card at one column's width. The count rides on a CSS variable set
imperatively via ResizeObserver, so reflowing the grid never re-renders this
large component.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Netcatty's AI / Skill+CLI integration sends marker-wrapped commands
(__NCMCP_xxx=0; { ... eval ...; }) straight into the user's interactive
shell. preload.cjs filters the PTY echo of those wrappers from the
visible terminal, but they still land in ~/.bash_history — making the
user's shell history hard to read after each AI session (#1126 user
report on v1.1.16).
Prefix the POSIX (bash/zsh/dash) and fish wrappers with a single space.
On the shells/configurations that already honor "ignore leading-space"
in history recording, those wrappers now skip the history file
entirely:
- bash with HISTCONTROL containing `ignorespace` (Debian/Ubuntu default
via /etc/bash.bashrc, also part of `ignoreboth` which is the most
common explicit setting)
- zsh with HIST_IGNORE_SPACE set (Oh-My-Zsh and most prezto templates
enable this)
- fish with a user-defined fish_should_add_to_history function (opt-in
via fish config)
Known limitations (no behavior change needed on netcatty's side):
- bash on bare RHEL/CentOS ships HISTCONTROL=ignoredups by default —
leading space is not honored. Users on those distros can opt in with
`HISTCONTROL=ignoreboth` in their ~/.bashrc.
- zsh without HIST_IGNORE_SPACE: same; add `setopt HIST_IGNORE_SPACE`.
- Fish without a custom history filter: leading space is not honored.
- PowerShell, cmd, network-device CLIs: unaffected (their wrappers are
not changed, and the persistent-history semantics differ).
This is intentionally a minimal change — 4 characters of behavior plus
the explanatory comments. We rely on the user's existing shell config
instead of trying to mutate HISTCONTROL ourselves at session start,
which would either be visible in the terminal echo, mis-fire on hosts
that already had ignorespace (deleting a real previous history entry),
or error on non-POSIX shells.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel
Package bumps:
- @zed-industries/claude-agent-acp 0.22.2 → @agentclientprotocol/claude-agent-acp 0.37.0
(old npm package is deprecated; scope rename)
- @zed-industries/codex-acp 0.10.0 → 0.15.0
- @mcpc-tech/acp-ai-provider 0.2.8 → 0.3.3
- electron-builder asarUnpack glob + bridge require.resolve switched to the new scope
After the upgrade Codex tool-call cards started showing the local
worktree path for every step — "Run /Users/.../netcatty-tool-cli session
--session …" — instead of the remote command. Three things lined up:
1. The new acp-ai-provider maps ACP's `title` to `toolName`, and Codex's
title is the full shell invocation it's about to run.
2. Codex local_shell ships args as ["/bin/zsh","-lc","<full>"], so the
old `typeof args.command === 'string'` branch in ToolCall never fired
and we fell through to printing `name` (i.e. the title).
3. The bridge serializes tool args under `args`, but the ACP adapter
only read `event.input`, so even when args were available the
renderer received {}.
Fixes:
- acpAgentAdapter: read tool input from both `event.input` and
`event.args` so bridge-serialized chunks and direct AI SDK chunks
both work.
- ai-elements/tool-call: new extractDisplayCommand() unwraps the shell
array, then the netcatty-tool-cli wrapper (exec/job-start … -- <cmd>),
and renders the real remote command. session/env/job-poll/etc. fall
back to short labels ("netcatty: inspect session", …) instead of
exposing the binary path.
- shellUtils.cjs: defensive JSON-parse the ACP wrapper input in case
the AI SDK ever stops auto-parsing it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(build): exclude bundled Claude CLI binaries from the installer
@anthropic-ai/claude-agent-sdk@0.3.x bundles the native Claude Code CLI
(~211MB per arch) as optional sibling packages. Including them would
silently regress Netcatty's "bring your own Claude" design — the project
has always required users to install Claude Code locally, and the entire
path-discovery flow exists precisely to honor that contract:
- useAgentDiscovery.ts scans the user's PATH for `claude` and writes
the absolute path into the agent config's CLAUDE_CODE_EXECUTABLE env.
- aiBridge.cjs runs normalizeClaudeCodeExecutableEnvForAcp on every ACP
spawn, forwarding the env var to the child process.
- The @agentclientprotocol/claude-agent-acp wrapper's claudeCliPath()
(acp-agent.js) prefers process.env.CLAUDE_CODE_EXECUTABLE over the
bundled binary and only falls back to sibling-package resolution when
the env var is empty.
So the right place to enforce the design is electron-builder: exclude
node_modules/@anthropic-ai/claude-agent-sdk-* from `files`. Dev mode is
unaffected (optional deps still install for `npm run dev`); only the
packaged installer drops the binaries, saving ~150MB. Users without
Claude Code installed get the same SDK error they got pre-upgrade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zed-industries/claude-agent-acp ships dist/index.js with a
`#!/usr/bin/env node` shebang. We bundle the package and unpack it from
asar, but Windows ignores the shebang entirely and macOS/Linux only
honours it when `node` is on the user's PATH. When `node` was missing,
the resolver fell back to spawning the bare `claude-agent-acp` command,
which only works if the user manually ran
`npm install -g @zed-industries/claude-agent-acp` — see #1118.
Run the bundled script through `process.execPath` with
`ELECTRON_RUN_AS_NODE=1` (matching `resolveMcpServerRuntimeCommand` in
the MCP server bridge) so the embedded Electron acts as the Node
runtime. This makes the bundled copy work with zero external deps on
every supported platform.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the AI sidebar is dragged narrow, the model chip used to wrap
to its own line (and the perm chip to a third line), wasting vertical
space. Switch the toolbar from flex-wrap to a single-row flex container
where the +/perm chips stay shrink-0 and the model chip absorbs all
the squeeze via min-w-0, letting the existing truncate kick in and
ellipsize the model label instead of wrapping.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027)
#1027 reported an old Huawei S7706 (SSH banner `SSH-2.0--`, empty
software-version field) where the legacy-algorithms toggle still
couldn't get a connection through. The debug log shows the handshake
makes it through every negotiation step, picks `ecdsa-sha2-nistp521`
for the host key, then dies at:
Handshake failed: signature verification failed
i.e. ssh2's strict RFC verifier rejects the ECDSA signature the
switch produces. OpenSSH on the same machine connects because its
known_hosts is already pinned to an RSA fingerprint, so it never
advertises ECDSA in the first place.
This adds two layers of escape hatch:
A. `host.skipEcdsaHostKey` (one-click) — drops every `ecdsa-sha2-*`
from the offered host-key list. Forces the fallback to
ssh-rsa / ssh-dss / ssh-ed25519 that those old stacks implement
correctly. Wired through ssh / sftp / port-forwarding bridges,
inheritable from the group default.
B. `host.algorithms` (advanced) — per-category override lists
(`kex`, `cipher`, `hmac`, `serverHostKey`, `compress`). When a
category's array is non-empty, it fully replaces the negotiated
list for that category. Exposed in a collapsible "Advanced
algorithm overrides" panel on both host and group settings,
inspired by Tabby's per-profile algorithm UI. Empty arrays
normalize to "use default" so picking zero algorithms in a
category doesn't bench the connection with
"no matching algorithm".
Overrides apply BEFORE `skipEcdsaHostKey` so the latter stays an
unconditional kill switch even if the user explicitly puts
`ecdsa-*` back into the host-key list.
Behavior with default values (neither toggle set, no override list)
is identical to before — zero change for hosts that aren't opted in.
Tests cover the new options on both `buildAlgorithms` and
`buildSftpAlgorithms`, plus group→host inheritance for both fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): address Codex review on #1116 — proper seed + jump-host carry + missing call sites
Four review findings from the Codex pass on #1116, all real:
1. AlgorithmOverridesPanel was seeding the first customization in a
category from `SUPPORTED_ALGORITHMS_BY_CATEGORY`, which contains
legacy algorithms (CBC, arcfour, MD5, ssh-dss, group1-sha1...).
Unchecking a single modern algorithm in a host that had legacy mode
*off* would silently start offering those legacy algorithms. Now
seeds from `effectiveDefaultAlgorithms(legacyEnabled)`, mirroring
what `buildAlgorithms` actually emits at connect time.
- New `effectiveDefaultAlgorithms` pure helper in
`domain/sshAlgorithmList.ts` with its own test suite (also asserts
the modern subset contains no SHA-1 KEX / CBC / arcfour / MD5)
- `AlgorithmOverridesPanel` takes a `legacyEnabled` prop
- `isChecked` now reflects the effective-default state for an
untouched category, so unchecked rows visually represent
algorithms the connection wouldn't currently advertise
2. The chain-mode jump-host loop in `sshBridge.cjs` was applying the
target host's `skipEcdsaHostKey` / `algorithmOverrides` to every
bastion hop, which is wrong when the bastion has its own settings.
This was actually a pre-existing issue with `legacyAlgorithms` too
— `NetcattyJumpHost` simply didn't carry any of these fields.
- `NetcattyJumpHost` gains `legacyAlgorithms`, `skipEcdsaHostKey`,
`algorithmOverrides`
- `createTerminalSessionStarters` populates them from each jump
host's own configuration
- The jump-host bridge call now reads `jump.* ?? options.*` so a
hop with its own setting wins, but unset hops still fall back to
the target's settings (preserves historic chain-wide behavior
when nothing is overridden)
3. `infrastructure/services/portForwardingService.ts` only forwarded
`legacyAlgorithms` to the port-forwarding bridge, so a host that
needed the ECDSA skip or advanced overrides could connect through
the terminal but its auto-start tunnels would still hit the
original handshake failure.
- Forward `skipEcdsaHostKey` and `algorithmOverrides` at the target
call site and at the jump-host map.
4. `application/state/sftp/useSftpHostCredentials.ts` built
`NetcattySSHOptions` for `openSftp` without any of the algorithm
fields — and on inspection it didn't even forward `legacyAlgorithms`
to begin with, so SFTP panes for legacy-mode hosts were silently
negotiating with the modern default list. Same gap at the jump-host
map.
- Forward all three fields at both the target and the jump-host map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(hosts): make the advanced-algorithm collapsible trigger a real button
The first cut used a plain underlined caption for the
"Advanced algorithm overrides" collapsible trigger. That blends into
the surrounding helper text and doesn't read as an interactive
control — users couldn't tell that the per-category checkbox editor
was reachable at all.
Match the project's existing collapsible-trigger pattern (see
`SerialConnectModal`): full-width ghost Button, label on the left,
ChevronDown / ChevronUp on the right that flips with open state,
controlled via `useState`. Applied in both `HostDetailsPanel` and
`GroupDetailsPanel`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(hosts): rename Legacy Algorithms card to SSH Algorithms; split Backspace into its own section
The card now carries three algorithm controls (Allow Legacy / Skip
ECDSA / Advanced overrides) plus a Backspace Behavior dropdown that
doesn't belong with the rest. Two cleanups:
1. Rename the card from "Legacy Algorithms" to "SSH Algorithms".
The original title only described the first toggle; the section
now covers the whole algorithm-negotiation surface, including the
ECDSA host-key skip and the per-category override editor.
- i18n key renamed `hostDetails.section.legacyAlgorithms`
-> `hostDetails.section.sshAlgorithms` in zh-CN / en / ru
- `HostDetailsPanel` references the new key
2. Move the Backspace Behavior control out of the algorithm card.
- `HostDetailsPanel`: new dedicated "Terminal Behavior" card
(TerminalSquare icon) placed between SSH Algorithms and
Keepalive. New i18n key `hostDetails.section.terminalBehavior`.
- `GroupDetailsPanel`: no separate Card scaffolding for group
defaults — moved Backspace to the bottom of the SSH section
(after Mosh) so it visually separates from the algorithm block
above without introducing a new card heading.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sftp): per-hop algorithm settings in SFTP chain (Codex review on #1116)
`sshBridge.cjs`'s jump loop already reads `jump.* ?? options.*` for
the three algorithm fields, but `sftpBridge.cjs#connectThroughChainForSftp`
was missed in that change and still applied the target host's
`options.legacyAlgorithms` / `skipEcdsaHostKey` / `algorithmOverrides`
to every bastion. A bastion that needed the ECDSA skip or a custom
algorithm list while the target didn't would still fail the SFTP
handshake before reaching the target.
Mirror the sshBridge fix: read each setting from the jump host first,
falling back to the target options when the hop didn't override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): keychain SSH exec + advanced editor seed honor inherited algorithm settings
Two findings from a local Codex review pass on the branch:
1. The keychain "export public key to host" flow opens its own one-off
SSH connection through `sshBridge#execCommand`, but the connectOpts
built there didn't include any `algorithms`. ssh2 then negotiated
with its built-in modern defaults regardless of what the host had
set, so a host that needs the ECDSA skip (or legacy mode) would
connect in the terminal but the keychain export would fail with
the original signature-verification error.
- `sshBridge.cjs#execCommand` now sets `algorithms` from
`payload.legacyAlgorithms` / `skipEcdsaHostKey` /
`algorithmOverrides`, mirroring `startSSHSession`.
- `useKeychainBackend`'s `execCommand` typing gets the three new
optional fields.
- `KeychainManager` forwards the host's `legacyAlgorithms`,
`skipEcdsaHostKey`, and `algorithms` when invoking the export.
2. The Advanced Algorithm Overrides editor seeded its first
customization from `form.legacyAlgorithms`, which is the host's
own field — it doesn't reflect a value the host *inherits* from
its group's default. A user in a group with `legacyAlgorithms=true`
editing a host that hadn't explicitly set the flag would see the
editor seed in modern-only mode, and saving could silently drop
the legacy algorithms the host actually needed.
- `HostDetailsPanel` passes `form.legacyAlgorithms ?? groupDefaults?.legacyAlgorithms`.
- `GroupDetailsPanel` adds an `inheritedLegacyAlgorithms` memo
resolved from the parent group chain via `resolveGroupDefaults`,
and the editor uses `form.legacyAlgorithms ?? inheritedLegacyAlgorithms`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(keychain): export-public-key honors group-inherited algorithm settings
Second-pass Codex review on PR #1116 flagged that the keychain
"export public key to host" flow now carries the three algorithm
fields end-to-end, but only reads them from `exportHost.*` directly —
which doesn't reflect values the host inherits from its group's
defaults. A host that left `legacyAlgorithms` / `skipEcdsaHostKey` /
`algorithms` unset but sat inside a group that turned them on would
work fine in the terminal (the terminal starter applies group
defaults before sending the IPC payload) but its keychain export
would silently fall back to ssh2's modern defaults and hit the
original signature-verification failure.
Resolve the effective host with `applyGroupDefaults` +
`resolveGroupDefaults` before the `execCommand` call, then read the
algorithm fields off the effective host. Requires plumbing
`groupConfigs` into `KeychainManager` (added as an optional prop,
forwarded by `VaultView`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(host-details): algorithm-overrides editor reads currently selected group's defaults
Third-pass Codex review caught that the editor seed was reading from
the `groupDefaults` prop, which is whatever group the host belonged
to when the panel opened. If a user switched the host into a different
group inside the panel and then opened the Advanced Algorithm
Overrides collapsible before saving, the editor would seed from the
old group's `legacyAlgorithms` flag and could save the wrong list.
The panel already memoizes `effectiveGroupDefaults` from
`form.group`/`defaultGroup`/`groupConfigs` for exactly this kind of
re-resolution (used by theme/font effective lookups). Read the
inherited flag from there instead of the stale prop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(algorithms): trim UI cipher list to algorithms ssh2 actually supports
Fourth-pass Codex review flagged that `SUPPORTED_CIPHER_ALGORITHMS`
included `blowfish-cbc`, `cast128-cbc`, and the `arcfour*` family,
but OpenSSL 3 disabled those primitives, so ssh2's `canUseCipher`
filter drops them from `SUPPORTED_CIPHER` at startup. Selecting any
of them in the Advanced Algorithm Overrides editor would make
`ssh2.Client.connect()` throw `Unsupported algorithm` synchronously,
turning the override into a "host now unreachable" footgun instead
of a narrowing knob.
Realigned each `SUPPORTED_*_ALGORITHMS` list to ssh2's actual
`SUPPORTED_*` constants:
- `SUPPORTED_CIPHER_ALGORITHMS`: dropped blowfish / cast128 / arcfour;
added the no-suffix `aes128-gcm` / `aes256-gcm` variants ssh2 also
accepts.
- `SUPPORTED_KEX_ALGORITHMS`: added `diffie-hellman-group15-sha512`
and `diffie-hellman-group17-sha512` (present in ssh2 but missing
from the UI list); reordered to ssh2's canonical order.
- `SUPPORTED_HMAC_ALGORITHMS`: reordered so the ETM/SHA-2 grouping
matches ssh2's `DEFAULT_MAC` and lookups are predictable.
Locked the invariant in `sshAlgorithmList.test.ts` with a new test
that asserts every UI-offered algorithm is a member of ssh2's
`SUPPORTED_*` for its category. A future ssh2 bump that drops an
algorithm we still expose will fail this test instead of silently
becoming a connect-time error for users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(algorithms): run KEX override through the runtime fixed-DH filter
Codex round-N flagged a gap in `applyAlgorithmOverrides`: when the
user supplies a custom KEX list via the advanced editor, the previous
implementation copied it verbatim into the negotiated `algorithms.kex`
field. The default builder already passes its KEX list through
`filterSupportedFixedDhKex` to drop fixed-DH groups the runtime
doesn't support (notably `diffie-hellman-group1-sha1` on
Electron/BoringSSL, which lacks modp2), but the override path
bypassed that filter — so an Electron user enabling legacy mode and
saving any KEX checkbox state would re-advertise group1-sha1 and the
handshake would crash with "Unknown DH group" instead of failing fast.
Apply the same `filterSupportedFixedDhKex` to the override list. New
test in `sshAlgorithms.test.cjs` exercises a simulated BoringSSL
runtime that lacks modp2 and asserts the override-filtered KEX no
longer includes group1-sha1, while group14-sha1 / group-exchange-sha1
remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(algorithms): run HMAC override through the FIPS MD5 filter
Codex flagged the same runtime-bypass pattern Round-N-1 fixed for KEX,
now in HMAC: `applyLegacyHmacAlgorithms` gates `hmac-md5` behind
`md5Supported()` so FIPS Node builds don't get it, but the UI's
`effectiveDefaultAlgorithms(true)` seed adds `hmac-md5` /
`hmac-md5-96` unconditionally. A user with legacy mode on who saves
any HMAC checkbox change would push those MD5 entries through
`applyAlgorithmOverrides`, which previously copied the override
verbatim — bypassing the FIPS gate and making ssh2 throw
"Unsupported algorithm" before negotiation.
New `filterRuntimeUnsupportedHmac` helper applies the same
`md5Supported()` gate to a user override. `applyAlgorithmOverrides`
routes the HMAC override through it (mirrors how the same function
already routes KEX overrides through `filterSupportedFixedDhKex`).
New test simulates a FIPS-disabled MD5 runtime and asserts MD5
variants drop from the final HMAC list while SHA-1 / SHA-2 entries
remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): jump-host overrides no longer inherit the leaf's algorithm overrides
Codex flagged that the jump-loop fallback I added for chain
convenience applied the target's per-host \`algorithmOverrides\` to
every bastion whose own override wasn't set, which is wrong: a target
restricted to e.g. \`serverHostKey: [\"ssh-rsa\"]\` would lock the hop
to ssh-rsa too and break negotiation against an Ed25519-only bastion.
The fallback IS still correct for \`legacyAlgorithms\` and
\`skipEcdsaHostKey\` — those are append/safety toggles that widen the
offered list, so propagating them to a bastion is safe and matches
the historic chain-wide behavior of \`options.legacyAlgorithms\`
(single-toggle convenience for a chain with one old leaf).
Treat \`algorithmOverrides\` strictly per-host instead. Same change in
both \`sshBridge.cjs\` and \`sftpBridge.cjs\` jump loops, with a
comment block explaining the asymmetry so a future refactor doesn't
"clean up" the distinction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui(host-details): surface inherited algorithm overrides in the advanced editor
Codex flagged a real gap in the inheritance model: when a parent
group has set algorithm overrides (e.g. \`algorithms.kex: [...]\`),
a host or child group under it can't simply Reset a category back to
NetCatty's defaults — \`applyGroupDefaults\` treats an unset host
field as "inherit", so the local Reset falls back to the group's
list rather than to ssh2's defaults. Cleanly distinguishing "reset
to NetCatty defaults" from "inherit from group" needs a new schema
field or sentinel, which is a non-trivial design change well beyond
the scope of this PR.
For now, surface the situation in the UI so the user understands
why Reset doesn't behave the way they might expect and where to go
to actually clear the restriction:
- \`AlgorithmOverridesPanel\` accepts an optional \`inheritedFromGroup\`
prop and, when populated, renders a blue inline notice listing the
inherited categories and directing the user to the group's
algorithm settings if they need to opt out.
- \`HostDetailsPanel\` passes \`effectiveGroupDefaults?.algorithms\`.
- \`GroupDetailsPanel\` adds a new \`inheritedAlgorithmOverrides\`
memo that resolves the same way the existing
\`inheritedLegacyAlgorithms\` does, and forwards it.
- i18n strings added in zh-CN / en / ru.
Follow-up (out of scope for this PR): if real users do hit this,
introduce a \`host.algorithms = null\` (or explicit
\`algorithmsOverride: boolean\`) sentinel and a Reset-to-defaults
button that uses it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): jump-host skipEcdsaHostKey is per-host; panel seeds from inherited overrides
Three findings from the latest Codex pass:
1 & 2. `skipEcdsaHostKey` on the leaf was still falling back onto
jump hosts in both `sshBridge.cjs` and `sftpBridge.cjs`. I had
classified it with `legacyAlgorithms` as a "safety widening" knob,
but Codex is right that it actually *narrows* the offered host-key
list by dropping every `ecdsa-sha2-*`. An ECDSA-only bastion (or
one where the operator pinned ECDSA via known_hosts) would still
negotiate when ECDSA is offered, but fails when the leaf's skip is
propagated to it. Same fix as `algorithmOverrides` last round:
strictly per-host. Only `legacyAlgorithms` keeps the chain-wide
fallback (append-only — it widens the offer, can never break a
hop that wasn't already failing).
3. `AlgorithmOverridesPanel` was seeding the first customization of a
category from the NetCatty/legacy effective default, ignoring any
list the host inherits from its group for OTHER categories.
Because `applyGroupDefaults` treats `host.algorithms` as an
all-or-nothing inherit boundary, the moment the user saved any
host-local override `{ cipher: [...] }`, the group's
`{ serverHostKey: ["ssh-rsa"] }` restriction silently dropped from
the effective host. Now:
- `toggleAlgorithm`'s first-click seed reads
`inheritedFromGroup?.[category] ?? effectiveDefault[category]`,
so customizing one category preserves the group's narrowing on
the others.
- `updateCategory` initializes `next` from
`{ ...inheritedFromGroup, ...value }` so saved overrides carry
the inherited categories alongside the host's own edits.
- `isChecked` reflects the inherited list when there's no local
value, so the visible checkbox state matches what would actually
be advertised.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(host-details): Reset preserves the inherited list instead of silently widening
Codex caught a follow-on bug from the previous "carry inheritance"
fix: pressing Reset on a category that's inherited from the group
deleted that category from \`host.algorithms\` while other host-local
or carried-inherited categories remained. Because
\`applyGroupDefaults\` treats \`host.algorithms\` as all-or-nothing,
the moment any host-local entry exists the group's \`algorithms\`
object stops being inherited as a whole — so the freshly-deleted
category fell back to NetCatty defaults rather than the group's
narrower list. Effective result: Reset on an inherited category
*widened* the offer instead of restoring it.
Reset now persists the inherited list verbatim onto the host when
the group has an override for that category, so "Reset" means "use
what this host would otherwise inherit" in all cases.
Also tightened \`isCustomized\` to suppress the "customized" badge
(and the per-category Reset button) when the host's stored list is
identical to the inherited list — those rows haven't really been
customized by the user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui(host-details): gate the inherited-notice / checkbox baseline on no host override
Codex caught the asymmetry between the read and write sides of the
panel. The write side (`updateCategory`, `toggleAlgorithm`, Reset)
intentionally carries `inheritedFromGroup` onto the host on the first
edit so the runtime's all-or-nothing inherit boundary in
`applyGroupDefaults` doesn't silently widen the offer. But the read
side (the inherited-notice banner and the `isChecked` baseline for
unfilled categories) was applying inherited values *unconditionally*
— including when the host already had any local `algorithms` object,
which makes `applyGroupDefaults` stop inheriting from the group as a
whole. Net effect on a host that only locally overrode `cipher`: the
UI claimed the group's `serverHostKey` restriction was still in
effect, while the runtime would actually use NetCatty's modern
defaults for that category.
Introduce `inheritedForDisplay`, defined as `value === undefined ?
inheritedFromGroup : undefined`, and route the notice + the
`isChecked` baseline through it. The write side keeps consulting the
unconditional `inheritedFromGroup` so the carry-over still happens on
first edit — that part is what makes the runtime behavior match what
the UI used to advertise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui(host-details): legacy/skipEcdsa toggles reflect group-inherited value
Codex caught that the Skip ECDSA toggle in both the host and group
panels read `form.skipEcdsaHostKey` directly, so a host whose group
turned the flag on (and `applyGroupDefaults` therefore applied it to
the runtime SSH negotiation) still saw the toggle rendered as off.
Worse, clicking it computed `!form.skipEcdsaHostKey` — which is `!undefined`
= `true` — so the first click could not actually disable the
inherited setting on a per-host basis; it would just store `true`
explicitly, the same effective state.
The Allow Legacy Algorithms toggle had the same pre-existing issue.
Fix both at once: each toggle now derives its enabled state from
`form.<field> ?? <inherited>` and the onToggle handler flips that same
effective value, so the toggle accurately represents what the runtime
would do and a single click off correctly stores `false`
(which `applyGroupDefaults` then leaves alone, breaking inheritance
for this host).
- `HostDetailsPanel` reads from `effectiveGroupDefaults`.
- `GroupDetailsPanel` adds an `inheritedSkipEcdsaHostKey` memo
alongside the existing `inheritedLegacyAlgorithms` and uses both.
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(terminal): use client OS for local-shell autocomplete clear sequence (#1112)
Synthetic fallback Host in TerminalLayer hardcoded os: 'linux', so the
'host.os || navigator.platform check' expression in Terminal.tsx never
ran the client-OS detection branch for local shells. Result: autocomplete
emitted Ctrl-U (\x15) for line-clear on Windows local PowerShell/cmd,
where it's rendered literally as ^U instead of erasing the input — the
popup live-preview, fuzzy-accept, and snippet-accept paths all leak it.
Fix both ends: Terminal.tsx prefers client OS detection for local
protocol via the existing detectLocalOs helper; TerminalLayer.tsx
populates the synthetic host's os field from detectLocalOs too, so
other host.os readers (notably the server-stats gate in
Terminal.tsx:642) also stop treating local Windows as Linux.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): keep non-local fallback host on 'linux'
The TerminalLayer fallback path also triggers for unsaved serial sessions
and orphaned remote sessions (where the saved host was deleted while the
session lived on). Terminal.tsx trusts host.os for non-local protocols,
so tagging the fallback with the Windows client OS would push POSIX
remote/serial shells onto the Windows autocomplete/path-completion
branch. Gate the client-OS detection on protocol === 'local'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #1110 added an \`// eslint-disable-next-line no-console\` above
\`console.warn\` when suppressing SDK reasoning/text state-machine
errors, but this file already permits \`console.*\` calls (there are
several pre-existing \`console.error\` lines in the same hook) so the
directive is unused and ESLint flags it:
\`\`\`
666:13 warning Unused eslint-disable directive (no problems were
reported from 'no-console')
\`\`\`
Removes the directive; the \`console.warn\` stays.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #1101 follow-up. When a third-party Anthropic-compat backend
streams thinking deltas without first emitting a `reasoning-start`
content-block signal (DeepSeek's \`-v4-flash\` is the canonical
offender), the Vercel AI SDK has nothing registered for the incoming
\`part.id\` and enqueues an \`error\` chunk on \`fullStream\` with the
text \`reasoning part <id> not found\` — once per orphan delta. The
analogous error exists for text parts.
These are internal SDK bookkeeping noise, not user-facing errors. Our
\`case 'error'\` handler was treating each one as a real error: it
appended an empty assistant message carrying the SDK string. The
visible damage was a wall of red banners in the chat panel — but the
worse damage was protocol-level. On the next turn we rebuild
\`sdkMessages\` from local history; the placeholder assistants slot
in between the assistant that holds the parallel \`tool_use\` blocks
and the subsequent \`role: 'tool'\` messages with their results. The
Anthropic SDK's \`groupIntoBlocks\` only merges *consecutive* tool
messages into a single user block, so the tool_results no longer
immediately follow their tool_use parent — and the backend rejects
the request as
\`400 messages.N: tool_use ids were found without tool_result blocks
immediately after\`. \`messages.N\` was literally counting our 12-ish
phantom assistants.
Adds \`isSdkStreamStateError(error)\` to
\`infrastructure/ai/shared/streamStateErrors.ts\` (matches
\`/^(reasoning|text)\\s+part\\s+\\S+\\s+not\\s+found$/i\` against
string / Error / \`{ message }\` shapes), and skips placeholder
emission for matching errors in the streaming hook. Drops a
\`console.warn\` so the noise is still visible in devtools when
debugging.
5 unit tests cover positive matches, the Error/object wrappings,
non-matches that should stay surfaced, and non-string inputs.
Refs #1101.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): serialize Catty terminal_execute calls per session
Issue #1101 problem 3. When the LLM emits multiple tool_use blocks in
one assistant turn (DeepSeek's Anthropic-compat happily does this for
parallel info-gathering), the Vercel AI SDK dispatches every tool
through `Promise.all(toolCalls.map(execute))`. All of them then race
into the same per-session mutex inside the main-process bridge
(`mcpServerBridge.reserveSessionExecution`). One wins; the rest get
`{ ok: false, error: "Session already has another command in
progress..." }` synthesized back as the tool result. The LLM sees a
turn full of synthetic errors instead of the answers it asked for, the
UI cards look stuck on "executing", and the Anthropic API has
sometimes rejected the resulting trace as
`tool_use ids were found without tool_result blocks`.
Adds an in-renderer Promise-chain queue keyed by
`${chatSessionId}:${terminalSessionId}` so the actual
`bridge.aiExec()` calls are issued one at a time per terminal. The LLM
can still parallel-call as many tool_use blocks as it wants — they
just resolve sequentially with real output. Approval prompts are kept
*outside* the queue: three parallel tool_use blocks still surface
three approval cards together, so the user can dispatch them in any
order rather than waiting for each prior command to finish before the
next prompt appears. The bridge-side mutex stays as defense-in-depth
for non-LLM paths (terminal_start, MCP, etc.).
`chainBySessionKey` is a self-contained helper with cleanup logic so
the queue map doesn't leak across many short-lived sessions:
- Each task awaits the previous tail via `.then(task, task)` so a
failure doesn't poison the chain.
- A non-rejecting wrapper is stored as the new tail to keep that
contract regardless of how the task settles.
- The cleanup `finally` only deletes the map entry when the current
tail is still ours, so a caller that arrived between
`set` and `finally` doesn't have its tail evicted.
Four unit tests cover dispatch ordering, rejection isolation,
per-key parallelism, and the cleanup path. Full suite 1255/1255.
Refs #1101.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): preserve LLM emission order + honor abort in serialized queue
Codex local review on the first cut of this branch flagged two real
issues plus a weak cleanup test:
1. **Approval order vs queue order** — the previous version awaited
approval *before* reserving a queue slot. With three parallel
tool_use blocks the user could approve B's prompt first, and B
would slip into the queue ahead of A, running out of LLM emission
order. Reserving the slot synchronously up front fixes that:
`Promise.all`-dispatched executes each grab a slot at the same
instant in their dispatch order, then wait on approval while
holding it, then await `slot.ready` and run.
2. **Abort not honored** — once approvals were settled, queued-but-
not-started commands ran to completion even after the user hit
Stop. Their results were ignored by the SDK but the side effects
still happened. Two `abortSignal` checks now short-circuit before
the queue wait and again after.
3. **Cleanup test was vacuous** — the previous "drains" test would
pass even if the cleanup logic were removed. Exposed
`getSessionExecutionQueueSizeForTests` and assert the Map is empty
after the only queued task settles.
Queue API now has two entry points: high-level `chainBySessionKey`
for run-it-when-it's-your-turn callers, and lower-level
`reserveSessionSlot` for callers (like `terminal_execute`) that
need to do non-blocking pre-work in parallel with the queue wait.
Two new tests cover the reservation-order-vs-prework-order guarantee
and the skip-without-serialized-work path.
Refs #1101.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): re-issue cancel on abort to close IPC-transit race
Codex local review (round 2) flagged a small remaining race: between
the abort check after \`slot.ready\` and the main-process bridge
registering the new exec into \`activePtyExecs\`, the user can hit
Stop. \`handleStop\` does issue \`aiCattyCancelExec\`, but if that
cancel IPC arrives at main *before* the exec has finished
registering, the cancel finds nothing to cancel and the exec keeps
running. Result: the user already cancelled, but the command runs
anyway.
Plug the gap by re-issuing the cancel from the tool's own abort
listener. Once the exec has registered into \`activePtyExecs\` (a
synchronous step on the main side once the IPC lands), the duplicate
cancel finds the entry and cancels it. \`cancelPtyExecsForSession\`
is already idempotent — it iterates the live tracker and skips
entries with mismatched \`chatSessionId\` — so double-firing from
both \`handleStop\` and the tool is safe.
Extends \`NetcattyBridge\` with the optional
\`aiCattyCancelExec(chatSessionId)\` method. Already exposed in
\`electron/preload.cjs\` so the runtime call works on the live
bridge; the type addition just makes it visible to the tool.
Refs codex review on #1101 problem 3 fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): register pending-cancel marker before SSH exec-channel opens
Codex local review (round 3) caught a remaining race in
`execViaChannel`: the cancellation marker is only registered inside
`sshClient.exec`'s open callback, which is async. If a cancel arrives
in the window between `sshClient.exec(...)` being dispatched and the
callback firing, `cancelPtyExecsForSession` finds nothing for the
session and is a no-op. The channel then opens, the marker
registers, and the command runs to completion — exactly the "user
already cancelled, but the command still ran" failure mode.
Plug the window by registering a *pending* marker synchronously
before `sshClient.exec`. The pending marker carries a `cancel()` that
just latches a `cancelled` flag and a `cleanup()` that is a no-op.
When the open callback fires it removes the pending marker and
checks the latch: if it tripped, close the just-opened stream and
resolve with `{ ok: false, error: "Cancelled" }`. If not, the normal
post-open registration takes over with the real `execStream` close.
The PTY (`execViaPty`) and raw-serial (`execViaRawPty`) paths
already register their marker before any async wait, so they don't
share this race.
Two regression tests in `ptyExec.test.cjs`:
- The pending marker is present immediately after `execViaChannel`
returns, before the open callback runs.
- A cancel during the pre-open window short-circuits the eventual
callback with `{ ok: false, error: "Cancelled" }` and closes the
now-unwanted stream.
Refs codex review on #1101 problem 3 fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): clean up pending-cancel marker when sshClient.exec throws sync
Codex local review (round 4) noted that if `sshClient.exec(...)`
itself throws synchronously — e.g. because the underlying ssh2 client
was destroyed between the session lookup and the actual `.exec`
call — the pending-cancel marker stays in `activePtyExecs`
indefinitely and the surrounding Promise rejects instead of
resolving with the normal `{ ok, error }` shape the tool layer
expects.
Wraps the `sshClient.exec` invocation in `try/catch` so:
- Pending marker is removed when the throw escapes.
- The Promise resolves cleanly with `{ ok: false, error: err.message }`
so the tool layer gets the same shape it does for every other
failure mode (closed stream, timeout, cancellation).
Adds a regression test covering this exact path: a fake client whose
`.exec` throws synchronously; the result is a clean failure and the
cancellation map is empty afterwards.
Refs codex review on #1101 problem 3 fix.
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(ai): provider switcher chip in the Catty Agent chat input
Catty Agent currently has no model chip in the chat input — modelPresets
is empty for the catty agentId so `hasModelPicker` is false. The
provider/model the chat ends up using comes from the global
`activeProviderId` / `activeModelId`, which means switching providers
requires opening Settings → AI → Providers. Closes the gap surfaced in
the original feedback on #1101 (problem 2) and partially addresses
#986 (per-context model switching) by going per-agent.
State layer adds an `agentProviderMap` (`Record<agentId, providerId>`)
alongside the existing `agentModelMap`. Both are written together when
the user picks from the new dropdown; together they form a per-agent
override that beats the global active provider/model when set. Cross-
window sync mirrors the agentModelMap pattern.
ChatInput grows a `providerSwitcher` prop carrying the enabled
ProviderConfigs, the selected (providerId, modelId), and an onSelect
callback. When supplied, the model chip switches from the Cpu glyph
to the provider's ProviderIconBadge + `providerName · modelId`, and
the popover renders a two-column layout — providers on the left
(icon + name + default model caption), that provider's known model(s)
on the right. ACP agents (Claude/Codex) are untouched because they
plumb their provider through the CLI, not the Vercel AI SDK.
AIChatSidePanel resolves `effectiveActiveProvider` /
`effectiveActiveModelId` for the catty agent from the per-agent map,
falling back to the global selection. handleSend now passes those
through to sendToCattyAgent so a provider picked in one Catty session
applies everywhere Catty runs.
For v1, each provider's right column shows just its configured
`defaultModel`. The two-level structure is in place to absorb richer
listings (cached /models, manual additions) later without UI
surgery — left a note in the popover code.
Refs #1101, #986.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(ai): flatten provider picker to a single list
Two-level dropdown was theatre — each ProviderConfig exposes exactly
one model (its `defaultModel`), so the right column ended up showing
the same string already captioned on the left row. Picking a provider
implicitly picks its model; one click is enough.
Also drops the `p.enabled !== false` filter on the picker list. The
user's expectation, confirmed by feedback on the first cut, is that
the chat-input list mirrors what Settings → AI → Providers shows. The
per-provider `enabled` toggle is an "active-ish" flag, not a
visibility gate; hiding disabled providers in the picker silently
made everything but the one currently active disappear, which is what
the reviewer hit.
Pares the popover down to a vertical list — provider icon + name +
default-model caption (mono if set, italic "configure default model"
hint if not) + a Check on the bound row. Removes the `hoveredProviderId`
state along with the right-column rendering since it's no longer
driving anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(ai): tune provider icon sizes in the chat input
Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar
chip alongside the truncated label; popover rows used the same sm, so
nothing differentiated the picker from the chip visually.
Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px
letter fallback) and uses it on the chip. Popover rows step up to md
(32px badge, 16px glyph) and the row padding grows accordingly so the
larger brand mark has room to breathe — the picker now reads like a
list of choices, the chip reads like a status line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): keep per-agent override honest in model fallback + chip label
Two codex P1/P2 findings on #1107:
P1 — When Catty has a per-agent provider override but no per-agent
model (agentModelMap['catty'] empty), cattyAgentModelId fell through
to the global `activeModelId`. That id belongs to whichever provider
was globally active, not the one Catty is now bound to, so e.g.
overriding to DeepSeek without a defaultModel happily sent gpt-4o
(global) to DeepSeek and produced a wrong-model error. The fallback
is now: stored agent model → override provider's defaultModel → empty.
Only the no-override path keeps the activeModelId tail; the
no-provider send guard catches the empty case for everyone else.
P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so
the chip always displayed *some* provider even when none was actually
bound (selectedProviderId missing). The rest of the pipeline (send
guard, agentProviderMap) treats that as "no provider", so the chip
was lying. Dropped the fallback; when nothing is bound the chip now
shows a generic Cpu glyph + "Select provider" label until the user
picks one from the popover. Adds `ai.chat.selectProvider` in en /
zh-CN / ru.
Refs codex review on #1107.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): purge per-agent binding when the bound provider is deleted
Codex local review (round 2) flagged that a saved Catty model id could
outlive its provider. The chain:
- catty bound to provider A (DeepSeek): agentProviderMap['catty']='A',
agentModelMap['catty']='deepseek-v4-flash'
- user deletes provider A
- cattyAgentProvider falls back to the global active provider (B, OpenAI)
- cattyAgentModelId still returned the saved 'deepseek-v4-flash'
- send dispatched the DeepSeek model id to OpenAI → wrong-model error
Two layers of fix:
1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now
looks at the override provider before trusting the stored model id.
If the override is stale (provider deleted), the stored model id is
treated as orphan and the resolution falls back to the global active
selection just like cattyAgentProvider already does.
2. **Cleanup on remove** (useAIState.removeProvider) — when a provider
is deleted, any agent whose agentProviderMap entry pointed at it
gets both maps cleared (provider override + saved model). Same
rationale: that model id is now meaningless against every other
provider. Mirrors the existing activeProviderId cleanup.
Adds a small agentProviderMapRef so removeProvider can snapshot which
agents to clean up without relying on the captured-at-callback-create
state (which would be stale).
Refs codex local review on #1107.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): include agentProviderMap in settings sync + block empty-model send
Codex local review (round 3) found two issues:
P2 — `agentProviderMap` was missing from the cross-device sync surface.
Added it to the sync key list, the build path, the apply path, and the
SyncPayload type alongside the existing `agentModelMap`. Sync tests in
`syncPayload.test.ts` now cover the new field on both the build and
apply sides.
P2 — Provider rows with no `defaultModel` were still clickable in the
chat-input picker, so selecting one would save a binding with empty
model id; the send path then dispatched an empty model name and the
SDK would surface a vague backend error. Two changes:
1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed
styling, tooltip pointing the user to Settings) when the provider
has no \`defaultModel\`. Click is suppressed.
2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring
the existing no-provider guard — surfaces \`ai.chat.noProviderModel\`
as an assistant message. Catches stale bindings (e.g. user edited
the provider's \`defaultModel\` to empty after binding) before they
reach the SDK.
Refs codex local review on #1107.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync
Codex local review (round 4) found two more leaks:
P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate
(which trims) but every other layer (cattyAgentModelId resolution,
the send-guard `!sendActiveModelId` check, the SDK call) compared the
raw string. A provider whose default model was " " could still reach
the SDK and surface a vague backend error. Normalized: trim at the
resolution boundary (catty model fallback chain) and trim before the
send-guard's existence check.
P2 — Sync apply did not reconcile per-agent bindings against the
incoming provider set. A payload could change `providers` without
shipping a fresh `agentProviderMap`, leaving local overrides pointing
to provider ids the synced set no longer includes — the same ghost-
binding bug that `removeProvider` already handles for explicit user
deletes. Added `pruneOrphanPerAgentBindings()` that runs after every
AI settings apply: it walks `agentProviderMap`, drops any entry whose
provider id isn't in the current `providers` list, and clears the
saved model id for those agents alongside it (mirroring removeProvider
cleanup). A new test in `syncPayload.test.ts` exercises the ghost-
binding case end-to-end.
Refs codex local review on #1107.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): notify open AI state of cross-device sync apply in the same window
Codex local review (round 4) flagged that `applySyncPayload` writes
straight to localStorage but `useAIState` only re-reads on browser
`storage` events. Those events only fire in *other* windows — the
window doing the apply (the one with the active chat panel) keeps
showing pre-sync providers and per-agent bindings until reload.
Concrete leak: cloud sync swaps in a new provider set, pruning runs,
localStorage is correct, but the open Catty chip still references the
ghost provider binding until the user reloads.
Extracts the existing `AI_STATE_CHANGED_EVENT` constant +
`emitAIStateChanged` helper out of `useAIState` and into a new
`application/state/aiStateEvents.ts` so non-React call sites (sync
apply, future IPC handlers) can fire it without pulling in the hook.
After AI settings apply, `syncPayload.ts` walks every AI key it
touched (including the agentProviderMap / agentModelMap that the
reconcile step may have mutated even when the payload didn't ship
them) and emits a same-window nudge. `useAIState`'s existing local
listener routes unknown keys back through its storage handler, so
each affected piece of React state rehydrates from localStorage.
Adds a regression test in `syncPayload.test.ts` that asserts the
expected `netcatty:ai-state-changed` keys are dispatched for the
providers + per-agent map keys.
Refs codex local review on #1107.
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 icon badge next to the Display Name input opens the icon picker
but had no visual cue — users had no reason to suspect it was
clickable, and reviewer feedback flagged it as too hidden.
Adds a primary-tinted ring on hover/focus plus a small pencil glyph
overlaid on the bottom-right corner of the badge (also hover/focus
gated). The badge itself doesn't change shape, so the layout stays
stable; the affordance is purely additive.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ai): customizable provider name, icon, and protocol style
Issue #1101 reported that the only way to wire DeepSeek through the
Catty Agent was to add an "Anthropic" provider with a DeepSeek base URL,
which (a) wasn't relabelable except on the "custom" providerId and
(b) implicitly conflated wire-protocol style with providerId, locking
"custom" to the OpenAI-compatible client.
ProviderConfig now carries three optional fields — `style`
(anthropic/openai/google), `iconId` (built-in brand glyph key), and
`iconDataUrl` (user upload). createModelFromConfig routes on the
resolved style first, falling back to providerId only for per-vendor
quirks (ollama's throwaway apiKey and openrouter's baseURL). Display
name becomes editable for every provider, not just custom.
The icon picker exposes a curated lobe-icons subset (MIT) covering the
common Anthropic/OpenAI-compatible third parties — DeepSeek, Moonshot,
Kimi, Qwen, Zhipu, Doubao, Mistral, Cohere, Grok, Perplexity, Groq,
Hugging Face — plus the existing six built-ins. Uploads are downsampled
to 64×64 WebP on a canvas so localStorage doesn't blow up. See
public/ai/providers/NOTICE.md for attribution.
Closes part of #1101 (problem 2). PR2 will reuse the new name/icon
plumbing in the Catty Agent chat input to surface a provider switcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(ai): bigger labelled icon tiles and auto-fill display name
The first pass shipped the icon picker as an 8-column grid of bare
20px badges. Reviewer feedback: the icons are too small to read at a
glance, every preset should announce its brand name, and a second
click on a selected preset should let the user back out.
Switches the grid to auto-fill 120px tiles with a 32px icon plus a
truncated label so the picker reads like a brand list, not a sprite
sheet. Selecting a preset now also writes the brand's canonical
English display name into the Display Name field — the picker labels
keep their "/ 中文" suffix as a bilingual hint, but a new `name` field
on each catalog entry supplies a clean string for autofill (e.g.
"Qwen / 通义" → "Qwen"). Re-clicking the selected tile clears
iconId/iconDataUrl but leaves the typed name alone, so users who
already edited the name don't lose their edit on accidental toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* polish(ai): add explicit close button to icon picker
The picker only opened via the icon badge above the Display Name field,
which left no obvious affordance to collapse it once a preset was
chosen — users either had to scroll past or click back up to the icon.
Drops a ghost Close button in the bottom-right of the picker's action
row (next to Upload/Reset) so dismissing the panel is one click and
visible without hunting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): wire model discovery to the resolved provider style
Codex review on #1105 flagged that ModelSelector still derives auth
headers from `providerId` (`x-api-key` only when providerId is the
literal "anthropic"). Now that ProviderConfig lets the user override
`style` independently, the form persists the override but the model
discovery call ignored it — so picking an Anthropic providerId pointed
at an OpenAI-compatible backend (or vice versa) would send the wrong
header and fail to list models even though chat routing already used
the override.
Extracts `buildModelDiscoveryHeaders(style, apiKey)` as a pure helper
in infrastructure/ai/. ModelSelector now resolves the protocol family
via `resolveProviderStyle` with the explicit `style` prop taking
precedence, then asks the helper for headers. ProviderConfigForm
passes the resolved style through. The `needsApiKey = providerId !==
"ollama"` shortcut stays as-is — that's a per-vendor concession, not a
style decision.
Refs codex review on #1105.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): apply per-vendor URL fallbacks across every style override
Codex review on #1105 caught that the openrouter (and ollama) baseURL
fallback was sitting inside the `style === 'openai'` branch, so a user
who picked OpenRouter providerId + Anthropic/Google style with an
empty baseURL would skip the fallback entirely. The SDK then routed
to its own default endpoint (api.anthropic.com /
generativelanguage.googleapis.com) using the user's OpenRouter key —
silent misrouting plus auth failures.
Pulls the per-vendor quirks out into a new pure helper
`resolveProviderEndpoint(config, style, safeApiKey)`. The URL
fallback now fires for every style — the user picked openrouter for a
reason, even if they overrode the wire format. The ollama-only
`'ollama'` literal apiKey swap stays gated on `style === 'openai'`
because Anthropic/Google clients need a real key, not the throwaway
placeholder.
Refs codex review on #1105.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): send x-goog-api-key for google-style model discovery
Codex review on #1105 flagged that buildModelDiscoveryHeaders bucketed
google-style providers with the OpenAI-compat default, so a model
refresh against any google-routed endpoint was sending `Authorization:
Bearer …`. That's the wrong auth header — Google Generative AI
rejects Bearer entirely and expects `x-goog-api-key` (or `?key=`).
The runtime chat path already uses `createGoogleGenerativeAI`, which
authenticates with the Google header family, so discovery was the
odd one out.
Adds an explicit `google` branch returning `{ "x-goog-api-key":
<apiKey> }` and updates the unit test. The default google providerId
never hits this path today (PROVIDER_PRESETS["google"] has no
modelsEndpoint), but the style override surface created in this PR
lets users compose providerId + style pairs where discovery does
fire — e.g. an openai/anthropic-style providerId pointed at a Google
endpoint via baseURL override.
Refs codex review on #1105.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): pick discovery endpoint from resolved provider style
Codex review on #1105 pointed out the lopsided fix in ae7394c8: I
switched discovery *headers* to honor the resolved `style` but left
the URL path coming straight from `PROVIDER_PRESETS[providerId]`. So
the moment style and providerId disagree the request shape is half
correct — e.g. Anthropic providerId + style=openai sends Bearer
(right) at `/v1/models` (wrong; the OpenAI-compat backend exposes
/models). 404s either way.
Adds `STYLE_DEFAULT_MODELS_ENDPOINT` (`anthropic → /v1/models`,
`openai → /models`, `google → undefined`) and a
`resolveModelsDiscoveryEndpoint(style, presetEndpoint)` helper. The
style's convention wins; the caller-supplied `presetEndpoint` is the
fallback for styles with no listing convention (currently just
google).
Behavior for stock configs is unchanged — every preset's existing
modelsEndpoint matches its style's default. Mismatches now line up
(headers + path together), and `custom` providers gain a sensible
discovery attempt when the user sets a style. My earlier inline reply
ducking this was wrong; codex's call was right.
Added three unit tests covering style defaults, the override-on-flip,
and the google-style passthrough.
Refs codex review on #1105.
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 group details panel rendered Startup Command as a single-line <Input>,
so users couldn't type newlines into it — only the first line ever made it
into the saved config, which then broke the multi-line sequencing behavior
shipped in #1096 for any host inheriting the command from its group.
Switch to a 3-row <Textarea> to match the per-host details panel, so a
multi-line command typed on the group is preserved end to end.
Refs #1083.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(autocomplete): add snippet completion source
* feat(autocomplete): merge snippets at the command position
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(autocomplete): place snippet tests where the test runner finds them
The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(autocomplete): wire snippets into the terminal + preview snippet command
* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history
The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers
In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun
Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(terminal): add global startupCommandDelayMs setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(terminal): add startup-command line-split and delay-clamp helpers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal): run multi-line startup commands in sequence with configurable delay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(terminal): expose startup command delay in Terminal settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(terminal): keep startup-command line content verbatim
splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): include startupCommandDelayMs in synced terminal settings
Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ai): add Claude auth-presence detection helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ai): surface actionable Claude auth errors and reap stuck agent processes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ai): add pure helpers for Claude config dir + env editor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ai): let users set Claude config dir and env vars in settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* polish(ai): harden Claude config env, de-dupe error text, label a11y
- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
if a user types them into the free-text env editor, so they can't clobber
the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
via htmlFor/id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ai): make the Claude auth & config section collapsible
The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): preserve raw env editor text; don't encourage plaintext secrets
P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).
P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): expand ~ in Claude config directory before passing to the agent
CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ai): recreate cached Claude provider when its env config changes
Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(terminal): add per-mode follow-theme resolver and storage keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(terminal): persist per-mode follow-theme selections in settings state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sync): include per-mode follow terminal themes in cloud sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n(terminal): add per-mode follow-theme picker strings
* feat(terminal): add type filter and auto option to theme picker
* feat(terminal): pick dark/light terminal theme when following app theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(terminal): don't flag the auto sentinel as a missing theme in the picker
The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n(terminal): align zh-CN dark/light wording with app convention
Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): match follow-theme preview fallback to runtime resolution
The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.
The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(terminal): bound the connection log with a chunk ring buffer
The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.
Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)
The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.
Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer
SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.
Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.
Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.
Related to #1084.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): drop orphaned flushTimeout reference in SSH close handler
The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #826: optional Option+←/→ word jump on macOS
Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).
The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#826: gate Option+←/→ word jump to macOS
The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1079: preserve remote file mode when rz overwrites a same-named file
#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)
Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1079: probe file mode with `stat -- "$n"` for dash-prefixed names
Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.
Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
in another chunk, so it only ever produced an empty chunk + a build
warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
eager graph via CloudSyncManager's static import, so the dynamic
`import()` couldn't be code-split anyway and only emitted a mixed
static/dynamic-import warning.
No behavior change; production build is warning-free.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.
Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.
Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1064: handle remote filename conflicts in rz handleUpload
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1064: SSH exec probe + remove for rz upload conflicts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1064: IPC for rz overwrite-conflict prompt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1064: renderer prompt for rz overwrite conflicts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1064: make upload plan index-based to preserve per-file decisions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1065: resolve terminal cwd through su/sudo for the SFTP locate
The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.
getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.
Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.
Verified on a real server (root -> su user -> cd /tmp):
before: /root after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)
When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.
Retry readlink on the same-uid login shell before falling back to home.
Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Select the interactive (tty-bearing) login shell deterministically (Codex review)
find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.
Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.
Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit
A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.
Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.
zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Anchor TMOUT auto-logout match to the banner's final line (Codex review)
The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.
A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.
Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1005: add live-preview keystroke calculator for popup autocomplete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1005: live-render the selected popup suggestion on arrow navigation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat #1005: live-render full path while navigating sub-directory panels
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test #1005: move live-preview test into the npm test glob
The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1005: center and refine the popup key-cap hint
Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1005: record the actual executed line on Enter, not the stale suggestion
Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix#1005: don't revert user edits when Escape closes the popup
Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.
A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).
Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:
- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length)
after the cursor, where trackedInput is a client-side reconstruction of the
command line (buffer heuristics + keystroke prediction, to mask SSH echo
latency). On hosts with non-standard echo — hardware bastion hosts / network OS
like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and
the ghost gets painted over characters the user already typed (`int` + ghost
`terface` -> `intterface`).
Add a fail-safe consistency check: on each post-echo render, if the real
terminal line before the cursor contains the tracked input followed by more
untracked, non-whitespace characters (reality is AHEAD of what we tracked),
hide the ghost instead of drawing it over real text. SSH echo latency is the
opposite case (the line is a prefix-behind of the tracked input) and is
deliberately not flagged, so the ghost stays responsive on slow links. The
check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so
it can only ever suppress a ghost that would otherwise corrupt — never change
correct behaviour.
This converts the recurring "ghost shows already-typed characters" bug into
"ghost simply doesn't show" on devices we can't track reliably.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
macOS keys the "Local Network" privacy permission on the main executable's
Mach-O LC_UUID (Apple TN3179). Electron's prebuilt binary is linked with LLD,
which derives the UUID from a content hash, so every app built from the same
Electron version ships the *same* LC_UUID even with a different bundle id. That
collision makes the grant unreliable: a user who enables Local Network for
Netcatty can still hit `connect EHOSTUNREACH` on LAN / VMware host-only
addresses, while loopback-forwarded connections work.
Add an electron-builder afterPack hook that rewrites the packaged macOS
executable's LC_UUID to a value derived deterministically from the appId —
stable across builds (so the grant survives updates) but distinct from every
other app. It runs before code signing, so signature/notarization cover the
patched binary. No-op on Windows/Linux.
Verified the rewrite on a copy of Electron's binary (LC_UUID changes, file
stays a valid Mach-O, deterministic) and added unit tests for the Mach-O
patcher (thin + fat) and the UUID derivation.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Electron's BoringSSL dropped several standard MODP groups from the named
crypto.createDiffieHellmanGroup() API — notably the 1024-bit Oakley Group 2
(modp2) that backs SSH's diffie-hellman-group1-sha1. ssh2 calls
createDiffieHellmanGroup('modp2') for that kex, so connecting to legacy
network devices that only speak group1-sha1 failed with "Error: Unknown DH
group".
The underlying DH math still works on BoringSSL via createDiffieHellman()
with an explicit prime, so add a compatibility shim that wraps
createDiffieHellmanGroup and falls back to the well-known prime constants
when (and only when) the runtime can't resolve a group by name. On OpenSSL
builds the original call succeeds and the fallback is never used.
The shim is installed in main.cjs before any ssh2-using bridge loads, since
ssh2 destructures createDiffieHellmanGroup at module load. Once installed,
the existing legacy-group probe detects modp2 as supported again and offers
group1-sha1, so affected devices actually connect (still gated behind the
per-host legacy-algorithms toggle).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fixed-DH-group support probe called crypto.createDiffieHellmanGroup()
for each MODP group to feature-detect runtime support. Under Electron's
BoringSSL, instantiating the large groups is pathologically slow
(modp18/8192-bit takes ~20s on first call), and the result is only cached
in-process, so the first connection after every app launch froze for ~24s.
The standard modern groups (modp14/16/18) are universally supported and
always pass the probe anyway, so treat them as supported without probing.
Only groups a runtime may genuinely drop (e.g. BoringSSL removed the weak
1024-bit group1/modp2) are still feature-detected; those fail instantly.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(terminal): separate prompt after unterminated command output
Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated.
Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion.
* fix review issue
* Fix prompt cache initialization
* Serialize terminal output writes for prompt breaks
* Keep terminal status lines ordered with output
* Fix prompt arming without command callback
* Keep prompt display breaks out of session logs
* Avoid prompt breaks for output suffix matches
---------
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.
Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
arm64 + x64 DMGs already downloaded by the release job, sed-patches the
Cask file in the tap repo, sanity-checks the result parses as Ruby, and
pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
stable-tag gate, downloads the macOS artifact bundle, then runs the
bump script with HOMEBREW_TAP_TOKEN.
Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix#969: auto-fill saved password into PAM-style keyboard-interactive prompts
Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.
The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.
Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Codex review: only auto-fill prompts that mention a password
The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.
Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Codex review (round 2): exclude OTP vocabulary from auto-fill
The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.
Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.
Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.
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 host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:
1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
landed in localStorage without a `fingerprint` field. The verifier then
re-derived the fingerprint from the stored `publicKey` blob on every
connect — a brittle path that produced a different value than ssh2 if
anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
keyType → changed" heuristic. Any imported record whose keyType failed
to parse would be promoted to a rotation warning the first time the
server presented a real algorithm, even though the user had never
actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
record plus a live ssh-ed25519 handshake) was being reported as
`changed` instead of `unknown`, even though we had no comparable
record for the algorithm the server presented.
Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.
Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
`normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
legacy records on hydration. Pure-JS SHA-256 keeps the migration
synchronous so it can run inline in `useVaultState` without async
plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
and on cross-window storage events. When anything changes on hydration
the migrated list is written back to localStorage so the next launch
starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
fingerprint-first, then strict (host, port, keyType) match for the
changed branch, then fall through to `unknown`. Two existing tests
that locked in the loose heuristic are updated to assert the new
(safer) behavior, and a new test covers the multi-algorithm
first-encounter case.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on #966 which added `hover:bg-accent` to the existing raw
`<button>` element. That element is `h-full w-10`, so the new hover
fill spanned the entire title-bar height — a giant vertical accent
strip instead of the small icon-button highlight we wanted.
Replace the raw element with the same shadcn `Button variant="ghost"
size="icon" h-6 w-6` that every other icon on the same row already
uses. Wrap it in a centered container that keeps the title-bar height
for window-control alignment and carries `app-drag` so the empty
space around the icon still drags the window; the button itself stays
`app-no-drag`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hovering the gear icon in the top tab bar left no visual response while
every other icon on the same row (AI, theme toggle, sync) lights up on
hover with the accent fill. The gear button is a raw `<button>` rather
than the shadcn `Button variant="ghost"` because it spans the full
title-bar height to align with the window controls, so it never picked
up the ghost variant's `hover:bg-accent`.
Adds the matching `hover:bg-accent` class so the gear behaves the same
as its neighbours. The inline `color` style for the resting state stays
in place; the accent fill on hover is what was missing.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The right-click menu on host cards in the Pinned and Recently Connected
sections only exposed Connect / Edit / Pin-Unpin / Delete, while the
canonical "All hosts" listing also offers Duplicate and Copy Credentials.
There is no reason to omit those two for hosts you've pinned or recently
opened — the underlying handlers are already wired up.
Add the missing entries in the same order as the All-hosts menu so the
three context menus stay visually identical.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The copy-host-address, broadcast and focus-mode buttons sit on the
per-host statusbar directly under the top tab bar. With the default
top-side tooltip placement, hovering any of them paints the tooltip
on top of the tab title above (the visible "Copy host address …"
covering "Rainyun-114.66.26.174" in the bug report screenshot).
Drop the tooltips on the bottom side instead, matching the
HoverCardContent panels already used for the CPU/Memory/Disk stats
buttons on the same bar.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix#919: harden built-in Telnet handshake for legacy gear
The built-in Telnet client failed to advance past the welcome banner on
some older switch firmware (HP ProCurve 2610 reported in #919) and, in
the same session, leaked snippets of subnegotiation payloads into the
terminal display as random-looking characters. Three independent
correctness gaps in the old implementation, all rolled into one PR:
1. The negotiation parser was stateless per chunk. An IAC sequence
split across TCP frames either dropped the lone IAC (lost command)
or, for IAC SB...IAC SE blocks whose terminator landed in the next
frame, fell through to "skip IAC SB and treat the rest as data" —
spilling the subnegotiation payload (TERMINAL-TYPE strings,
environment data) into the user's terminal as garbage.
2. The client was purely reactive — it only ever responded to options
the server raised. Quite a bit of legacy equipment waits for the
client to commit to SUPPRESS-GO-AHEAD / TERMINAL-TYPE / NAWS before
it will continue past its banner, so connections silently hung at
"Press any key to continue" forever.
3. Outbound user input was never IAC-escaped, so any 0xFF byte the user
pastes (or that an alternate input encoding emits) would be read by
the peer as the start of a command and eat the following byte.
Approach:
- New `electron/bridges/telnetProtocol.cjs` owns RFC 854 framing as a
pure module. `createTelnetParser` is a stateful machine that buffers
any partial command (lone IAC, IAC + verb, unterminated SB) across
feeds and replays it once the rest arrives. Emits clean stream
bytes, option commands and complete subnegotiations through
callbacks. `escapeIacForWire` doubles 0xFF bytes on the way out with
a cheap fast-path for the common (no 0xFF) case.
- `terminalBridge.cjs` flips telnet handling into a lazy mode: until
the peer sends an IAC byte the connection is plain passthrough, so
raw-TCP-on-port-23 services are not corrupted by the protocol layer.
Once the protocol activates, we proactively request DO
SUPPRESS-GO-AHEAD, WILL TERMINAL-TYPE and WILL NAWS, and track those
in a `requestedOptions` Set so the peer's acknowledgement does not
trigger another reply (the classic negotiation loop).
- TERMINAL-TYPE is now advertised as "XTERM-256COLOR" (upper-case);
legacy boxes that case-sensitive-match termcap names recognise it.
- Resize-driven NAWS subnegotiations now only fire after the protocol
has actually activated, so a passthrough session is never poisoned.
- Outbound writes for telnet sockets convert strings to UTF-8 buffers
and run them through `escapeIacForWire`, so paste of binary content
and non-ASCII input encodings round-trip safely.
Tests:
- 17 unit tests in `telnetProtocol.test.cjs` cover normal data,
option commands, subnegotiation (including IAC IAC inside payload),
every cross-frame split point (lone IAC, IAC + verb, mid-SB), the
specific regression that previously leaked SB payload as data,
ordering of data vs command callbacks, and the IAC escape helper.
- Existing 18 telnet auto-login tests still pass, exercising the
end-to-end socket → parser → renderer path. Full suite: 825 / 0 / 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address review: per-direction Telnet negotiation tracking
RFC 858 §"Default Specification" treats WILL/WONT and DO/DONT as two
independent option streams. The first revision of this PR used a single
`requestedOptions` Set keyed by option byte, which incorrectly swallowed
a peer's independent request on the opposite direction whenever we had
our own request still pending for the same option.
Concrete failure mode (highlighted by code review on the PR): we send
`DO SGA` and the peer simultaneously sends `DO SGA` asking us to enable
SGA on our outgoing side. The old check matched the peer's DO against
our pending DO and returned silently, leaving the peer's request
unanswered — strict implementations would either time out or proceed in
the wrong mode.
Fix: split pending requests into `pendingDoRequests` (we sent DO,
awaiting WILL/WONT) and `pendingWillRequests` (we sent WILL, awaiting
DO/DONT). Acknowledgement matching is now direction-aware; the peer's
independent request on the orthogonal direction is treated as a fresh
negotiation and replied to.
While in there, the related bug uncovered by reviewing this code: when
the peer's `DO NAWS` acknowledges our own `WILL NAWS`, we previously
just dropped it on the floor — but the actual window-size SB payload
needs to follow the WILL handshake either way (whether the DO is an
acknowledgement of our WILL or an independent fresh request). The
negotiator now always pushes the size subnegotiation on `DO NAWS`.
Refactor: the negotiation policy lives in a new
`createTelnetNegotiator` factory inside `telnetProtocol.cjs`, separate
from the parser. That keeps `terminalBridge.cjs` thin and — more
importantly — makes the policy directly unit-testable. 13 new tests
cover the bidirectional-collision regression, the missing NAWS
follow-through, fresh vs ack handling for each verb, the canonical
handshake sequence, unsupported-option WONT/DONT replies, the
TERMINAL-TYPE SEND→IS roundtrip, and the 80×24 fallback for invalid
sizes.
Total: 30 parser+negotiator unit tests, 18 existing telnet auto-login
integration tests, full suite 838 / 0 / 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes addressing both halves of #958:
1. IPv6 highlighting
The built-in 'URL, IP & MAC' rule only shipped URL, IPv4 and MAC
patterns, so compressed IPv6 addresses such as 2001:11:22:33::5 or
fe80::d2dd:bff:fe79:f2bb were never highlighted. Add an IPv6 regex
covering full and compressed forms (including ::1 and leading-/trailing-
:: variants) and merge it into the same 'ip-mac' rule's patterns. The
normalizer's existing "fill missing defaults" path means existing users
pick this up on next start with no migration step.
2. Editable built-in rules
Add an optional `customized` flag to KeywordHighlightRule. When false /
absent, normalize re-syncs the rule's label/patterns with the shipped
defaults (so future default-pattern upgrades reach users automatically).
When true, normalize keeps the user's label/patterns/color/enabled
verbatim, allowing built-ins like 'ip-mac' to be tailored.
SettingsTerminalTab:
- Pencil icon now appears on built-ins too. Editing one routes through
the same dialog and flips `customized` on save.
- The pattern field becomes a Textarea so multi-pattern built-ins (e.g.
'error' ships seven spellings) can all be edited in one go.
- A per-rule "↺" reset icon appears on customized built-ins and restores
the shipped label/patterns while preserving the user's color/enabled.
- The footer's "Reset to default colors" button is broadened into
"Reset built-ins to defaults", restoring every built-in to shipped
label/patterns/color and clearing `customized`.
Tests:
New domain/keywordHighlight.test.ts (6 tests) covers IPv6 matches for
both #958 examples plus loopback and full-form, IPv4/MAC still match,
normalize migrates legacy non-customized 'ip-mac' to include IPv6,
normalize preserves customized patterns, and normalize keeps user
custom rules verbatim. Full suite: 808/0/3.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix#954: unify Tooltip styling + replace native selects
Replace native HTML title= tooltips and native <select> dropdowns
with the existing Radix-based Tooltip / Select components so they
share the app's rounded styling, theme tokens and i18n pipeline.
Adds a global TooltipProvider in AppWithProviders so every
descendant Tooltip works without a per-file Provider wrapper.
Scope (driven by the issue #954 examples and "全部都处理" follow-up):
- TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts
/ Theme / AI Chat / Move panel / Close panel.
- TopTabs middle bar: quick switcher, more tabs, AI assistant, theme
toggle, settings; window-control buttons (min/max/close), tray
close and hotkey reset/disable have their native title dropped per
the user's explicit opt-out ("可以不用Tooltip,直接全局禁用
原生title 属性").
- AI panels: AIChatSidePanel session history / new chat / delete,
ConversationExport, AgentSelector, ChatInput attach / expand /
permission, ModelSelector, ProviderCard, ai-elements/tool-call.
- SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow,
SftpPaneToolbar, SftpTabBar, SftpTransferQueue.
- Settings: SettingsPage close, SettingsAppearanceTab theme/accent
swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab
crash-log paths and global hotkey reset.
- Host vault: HostDetailsPanel (clear / suggestions / show-password /
key path / browse key), GroupDetailsPanel, KnownHostsManager,
ConnectionLogsManager, KeychainManager, SyncStatusButton,
CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel,
Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator.
- Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar,
TerminalConnectionDialog, TerminalSearchBar.
- Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab).
- TrayPanel session rows and port-forwarding rows.
Native <select> migrated to custom Select component:
- SerialConnectModal (data bits, stop bits, parity, flow control)
- SerialHostDetailsPanel (same four fields)
- HostDetailsPanel backspace behavior
- GroupDetailsPanel backspace behavior
- SettingsTerminalTab local shell picker
- terminal/ThemeSidePanel font weight
Hardcoded English strings extracted to i18n. New keys for both
en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory,
attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts.
resetToDefault. Inline help text on SnippetsManager package-name input
removed because the same hint is already shown in a visible <p> below
the input.
Existing per-file <TooltipProvider> wrappers (SnippetsManager,
ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy
section) are left in place — they nest harmlessly under the global
provider and stay self-sufficient for component tests.
Tests:
- tsc clean for changed files (pre-existing repo-wide errors
unrelated to this PR).
- All 802 tests pass (3 skipped pre-existing).
- HostDetailsPanel.proxyProfile.test and TextEditorPane.test
updated to wrap with TooltipProvider, matching the runtime
context now needed by the migrated components.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix#954: wrap Settings + Tray windows with TooltipProvider
Settings and the tray panel mount as separate Electron windows with
their own React root in index.tsx, so they do not inherit the global
TooltipProvider added under AppWithProviders. After the unified
Tooltip migration, any settings tab that used a Tooltip (Appearance,
Application, FileAssociations, System, Shortcuts, Terminal, AI
ProviderCard, AI ModelSelector) — and TrayPanel — threw
"Tooltip must be used within TooltipProvider" and rendered nothing.
Wrap both branches with TooltipProvider at the same level as
ToastProvider in index.tsx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useVaultState hydrates knownHosts asynchronously — its init awaits the
decryption of hosts, keys, identities and proxyProfiles before reading
knownHosts from localStorage. The state is briefly [] at boot even when
localStorage has saved entries.
The host-key verifier introduced in bce33f34 reads the renderer's
knownHosts state at connect time. Any SSH connect that fires inside
that hydration window (manual click or auto-restored session) sees an
empty trust list, marks every host as unknown, and prompts again. The
fix accepted by the user is saved to localStorage, but next restart
the same race repeats, giving the impression that fingerprints are
never persisted.
Use the existing getEffectiveKnownHosts helper at the two sites that
feed the SSH connect path (VaultView + TerminalLayerMount). The helper
falls back to localStorage while state is still settling, mirroring
the same pattern already applied to sync payloads (App.tsx:479).
Memoised on the knownHosts state so the prop reference is stable and
the TerminalLayer/VaultView React.memo equality checks still hold.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sftp): add drive switcher dropdown for local Windows panes
On Windows, the SFTP breadcrumb's first segment (drive letter) now shows
a dropdown to switch between available drives. This makes it easy to
navigate across drives without manually editing the path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sftp): probe drives async to avoid blocking main process
fs.accessSync in the listDrives IPC handler could stall the Electron
main process for seconds per disconnected mapped drive or empty optical
drive. Use fs.promises.access with Promise.allSettled so the 26 probes
run in parallel without blocking the event loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Adds a small clipboard-copy icon next to the host label / status dot in
the terminal pane's statusbar. Clicking copies the host's hostname
(IP or DNS name — what users called "machine IP" in #951) to the
clipboard and surfaces a toast.
The button only renders for non-local SSH/serial/telnet sessions —
local shells don't have an addressable hostname so showing it would
be confusing.
Placed in the pane statusbar (not the top tab) because the statusbar
is per-host: a workspace pane carries exactly one host, so the button
always identifies the right address. Top tabs in a workspace can share
multiple panes / hosts and would be ambiguous.
Visual treatment matches the surrounding stats buttons: 10px icon,
inline with the existing host label + status dot, opacity-60 →
opacity-100 on hover, `title` attribute for the tooltip to match the
pattern of the CPU/MEM/disk stats triggers right next to it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)
Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.
Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:
Domain:
- Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
+ `keepaliveCountMax?: number`. When override is true, the host's
values are used; otherwise the global TerminalSettings values apply.
Per-field fallback so a host can override interval only or countMax only.
- TerminalSettings gains `keepaliveCountMax: number` so the second knob
(number of unanswered keepalives before declaring dead) is no longer
hardcoded at 3 in the bridge.
- DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
network glitches don't trip the dead-connection check; an actually
dead session is detected within ~5 minutes. Existing users with 0
saved keep their value (no migration) — they were the #581 router
cohort and their setup still works untouched.
Plumbing:
- domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
five unit tests covering both directions of the override flag and
per-field fallback.
- components/terminal/runtime/createTerminalSessionStarters.ts uses the
resolver when building startSSHSession options.
- electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
(defaulting to 10) at both connection sites (direct + jump host) and
still routes interval=0 through to a fully disabled keepalive
(preserving #581's escape hatch).
UI:
- Settings → Terminal → Connection grows a second input next to the
existing interval: "Max unanswered keepalives".
- Host details panel gains a Keepalive section with a "Override global
keepalive" toggle that, when on, exposes per-host interval +
countMax inputs and an inline hint when interval = 0 (explaining
the implications). Same visual pattern as the existing Legacy
Algorithms section.
Sync:
- keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
field rides existing sync infrastructure. Per-host fields ride the
hosts array passthrough automatically (older clients receiving them
ignore unknown fields, per the existing lenient sync contract).
i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): resolve keepalive per jump host, not just the final target
Addresses codex review on PR #947:
https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx
The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:
router (bastion, needs keepalive=0) → cloud target (needs 30s)
would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.
Fix:
- NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
- createTerminalSessionStarters runs resolveHostKeepalive() per
jumpHost when building the chain, so each hop carries its own
resolved pair.
- sshBridge.cjs's chain connector reads jump.keepaliveInterval /
jump.keepaliveCountMax for each hop, falling back to the call's
target-level options for backward compatibility with older
serializers that don't yet populate the per-hop fields.
The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): per-host keepalive for SFTP + port forwarding too
Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.
Plumbing:
- useSftpHostCredentials / buildSftpHostCredentials: accept optional
terminalSettings; call resolveHostKeepalive() for the target and
each jump entry; emit keepaliveInterval / keepaliveCountMax in the
returned NetcattySSHOptions.
- useSftpConnections + useSftpState + SftpStateOptions thread the
setting down. SftpSidePanel passes the global terminalSettings prop
it already has from TerminalLayer.
- portForwardingService.startPortForward: accepts terminalSettings
as an 8th argument, resolves per-host (target + each jump), and
populates the bridge payload.
- usePortForwardingState.startTunnel and usePortForwardingAutoStart
forward the new parameter; App.tsx supplies terminalSettings (via
a ref in the once-on-launch auto-start effect so changing global
keepalive later doesn't re-fire it).
Bridges:
- sftpBridge.cjs target connect: now also reads keepaliveCountMax
from options (was hardcoded 3). 10s/3 stays as the bridge-level
fallback to preserve the #669 protection when the renderer hasn't
supplied a value.
- sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
jump.keepaliveCountMax, then falls back to the target-call options
(matches the symmetric SSH bridge change).
- portForwardingBridge.cjs: reads keepaliveInterval /
keepaliveCountMax from the IPC payload; same 10s/3 fallback.
Types:
- NetcattyJumpHost already grew keepalive fields earlier; this
commit also adds them to PortForwardOptions so the IPC contract
is explicit.
End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges
Addresses codex review on PR #947:
- https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
- https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx
The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:
- "user explicitly resolved interval = 0" (host with keepaliveOverride
+ interval=0; the whole point of the override)
- "no value supplied at all" (legacy serializer)
Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.
Fix: bridges now distinguish on `== null`:
- positive value → honor it
- explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
dead-connection check entirely on this connection)
- undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
protection for older callers that pre-date per-host plumbing)
Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): plumb terminalSettings to all remaining keepalive call sites
Addresses codex review on PR #947:
- PortForwardingNew + TrayPanel were not passing terminalSettings into
startTunnel, so tunnels started from the main port-forwarding UI or
from the tray menu silently used the FALLBACK 30/10 instead of the
user's actual global keepalive settings. Hosts inheriting global
policy could see different behavior depending on the entry point.
- SftpView was not threading terminalSettings into useSftpState, so
SFTP connections opened from the main tab UI also fell back to the
same hardcoded default and ignored the user's settings.
Wiring:
- PortForwardingProps gains `terminalSettings`; VaultView accepts it
on the same prop and forwards from its own new prop; App.tsx
supplies it from useSettingsState. The startTunnel call site uses
it directly and includes it in the useCallback dep list so the
handler updates when settings change.
- SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
forwards it; the sftpOptions memo includes it in its dep list.
- TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
wrapper (which already calls useSettingsState for uiLanguage)
passes it down so the standalone tray window agrees with the main
window's settings.
Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.
Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): include terminalSettings keepalive fields in memo comparators
Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.
Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a TUI app enables SGR mouse tracking (opencode, tmux with
`mouse on`, vim with `set mouse=a`, etc.), Terminal.tsx attaches a
capture-phase contextmenu listener that calls
stopImmediatePropagation. The original purpose is to bypass xterm.js's
own right-click handler — which calls textarea.select() and dismisses
TUI popup menus — but stopImmediatePropagation also kills the bubble
that React's onContextMenu delegation relies on, so
TerminalContextMenu's handleRightClick never fires.
Result: with `rightClickBehavior` set to "paste" (or "select-word"),
right-click silently does nothing inside any mouse-tracking TUI. Menu
mode still works because Radix opens via pointerdown (not affected by
the contextmenu capture block). Middle-click paste works because its
auxclick listener in createXTermRuntime is also unrelated to
contextmenu.
Fix: have the capture handler itself dispatch the user's chosen
right-click action when it intercepts the event. terminalContextActions
already exposes onPaste / onSelectWord; mirror them into a ref so the
once-bound capture handler can call the current implementation
without re-binding on every action identity change.
'context-menu' mode is intentionally not handled in the capture path —
Radix's pointerdown listener opens the menu independently.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(fonts): add CJK font pairing composition module
Introduces composeFontFamilyStack() which builds the xterm fontFamily
CSS string at runtime from:
- the user's primary Latin font
- an explicit CJK font (TerminalSettings.fallbackFont) if set
- otherwise a per-Latin-font recommended CJK pairing
- a hardcoded system CJK fallback stack
- a Nerd Font icon fallback stack
- the universal monospace generic
14 unit tests cover composition order, deduplication, OS defaults,
quoting, and recommendation override behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(fonts): expose raw Latin families and add CJK-coverage entries
- TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack;
composition is deferred to runtime via composeFontFamilyStack().
- Drops withCjkFallback helper from this module and its caller in
lib/localFonts.ts.
- Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC,
Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal): compose font-family stack with user-configurable CJK fallback
resolvedFontFamily now passes through composeFontFamilyStack(), which
prepends the user's TerminalSettings.fallbackFont (if set) ahead of the
per-Latin-font recommended CJK pairing and the system fallback stack.
The platform argument is derived from navigator.platform inside the
useMemo, so the same Latin font may pair with PingFang SC on macOS and
Microsoft YaHei UI on Windows out of the box.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(settings): add CJK font picker to terminal settings
Adds a new "CJK font" select row right under the main font selector in
the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an
already-existing-but-unused field), so this needs no schema or sync
payload change.
Default value "Auto" leaves fallbackFont empty, which lets the new
per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically.
Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft
YaHei UI, etc.) takes precedence over the per-font pairing.
Includes en + zh-CN i18n strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(sync): cover fallbackFont round-trip + legacy payload tolerance
Four new test cases verify cloud-sync compatibility for the new CJK
font setting:
- buildSyncPayload includes fallbackFont when set
- buildSyncPayload omits fallbackFont when unset
- applySyncPayload writes incoming fallbackFont to TERM_SETTINGS
- applySyncPayload from a legacy client (no fallbackFont) does NOT
wipe the local value — critical for old-to-new upgrades
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(fonts): add font availability detection (canvas + document.fonts API)
Three-layer detection used by isFontInstalled(family):
1. Known @fontsource-bundled families (e.g. JetBrains Mono) always
count as installed.
2. document.fonts.check() — picks up @font-face and system-loaded fonts.
3. Canvas width measurement against serif / sans-serif / monospace
fallbacks; only counts if the target font produces a width that
differs from ALL three generics for a probe string.
detectInstalledWithContext is a pure function taking an injected
measurement context, which keeps the canvas / DOM behind a seam and
lets the logic be unit-tested without a browser. 11 tests cover
quoted-family parsing, the three-generic-fallback rule, bundled
short-circuit, and document.fonts.check fast-path.
Results are cached per process; clearFontAvailabilityCache() invalidates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(fonts): filter dropdowns to fonts actually installed on this machine
Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI
/ PingFang SC etc. to the terminal font dropdown, but users who don't
have these installed would still see them and pick them — resulting in
"I changed the font and nothing happened" confusion.
This commit filters both dropdowns through isFontInstalled():
- TerminalFontSelect: drops any built-in or system-discovered font
that detection can't render. If filtering would leave fewer than 4
fonts (detection misfire safety net), shows the full list.
- TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops
concrete CJK choices that aren't present on this machine.
Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.
Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): never recommend proportional CJK fonts for terminal use
The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).
Changes:
- TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
Hiragino Sans GB from the dropdown. Legacy explicit selections
still surface as a synthetic "not recommended" option so users can
see and re-pick.
- CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
Mono SC, NSimSun, SimSun. Proportional fonts removed.
- PER_FONT_CJK_PAIRING: every entry now points at a true monospace
CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
Mono SC, which the next commit bundles via @font-face.
- getDefaultCjkFallback: Windows = SimSun (always installed,
monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
Noto Sans Mono CJK SC. A regression test enforces that no
per-OS default is a known proportional font.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace
Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.
Details:
- public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
box-drawing — the everyday-Chinese coverage that matters for a
terminal. Rare CJK Ext-A/B/historical chars fall through to the
system fallback stack.
- public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
by the license.
- index.css: @font-face declaration with font-display: swap so the
user doesn't see a flash of nothing while the woff2 loads.
- KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
availability filter doesn't hide it.
Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): use Local Font Access API as the authoritative install check
document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).
This commit pivots the detection chain:
- lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
set of installed family names from queryLocalFonts(), reusing the
same underlying call as getMonospaceFonts() via a shared cache.
- lib/fontAvailability.ts: drops the document.fonts.check fast-path.
Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
been populated, isFontInstalled answers from membership lookup
directly — no canvas guessing. Canvas remains as a fallback for
environments where the Local Font Access API is unavailable or
permission is denied.
- application/state/fontStore.ts: during initialize(), runs the
monospace-only query and the full-system-families query together,
then pipes the result into fontAvailability.
- TerminalFontSelect: with authoritative data, drops the "if filtered
list is suspiciously small, show all" safety net. Empty would now
really mean empty (highly unlikely since Sarasa Mono SC is bundled).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown
Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.
Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.
Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font
Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).
- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
catches any future mislabeled body-text font from being added to
the terminal dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack
Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).
The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.
The composed stack now reads:
<primary>, monospace, <userFallback>, <recommended-cjk>,
<system-cjk-stack>, <nerd-font-stack>
Per-glyph CSS fallback behavior:
- Latin → primary if installed → monospace generic. Cell width
stays consistent.
- CJK → primary (no) → monospace (no Chinese glyphs) → walks into
CJK fallbacks.
- Nerd PUA → falls past all of the above into the Nerd Font stack.
Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): dedupe Local Font Access API calls under concurrent init
Addresses codex P2 review on PR #940:
https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx
fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).
Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.
Adds lib/localFonts.test.ts with three regression tests:
- concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
- sequential repeats also reuse the resolved promise
- missing API returns null authoritative set (canvas fallback signal)
Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): retry LFA on transient failure + notify on availability changes
Two follow-up fixes from codex P2 review on PR #940:
1) queryAllSystemFontsOnce() previously kept its in-flight promise even
when queryLocalFonts threw. Subsequent callers reused the cached
empty result for the rest of the session, so any transient failure
at boot (permission state not ready, AbortError, etc.) permanently
blinded the rest of the app to installed fonts. Catch now clears
queryPromise so the next caller retries. Regression test added.
2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
.visibleFonts were memoized on [value] / [fonts, value] only, but
the filter calls isFontInstalled() which reads module-level
systemFamilies — a value that arrives asynchronously after the
initial render. The memos never recomputed when authoritative
availability data landed, so the dropdowns could continue showing
stale "filtered" results until the user changed selection.
fontAvailability now exposes subscribeFontAvailability() and
getFontAvailabilityVersion() (monotonic counter bumped on
setSystemFamilies / clearFontAvailabilityCache). Both selects
subscribe via useSyncExternalStore and include the version in
their memo deps; tests cover subscriber notification and version
monotonicity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): migrate host/group deprecated font ids + localize CJK labels
Two follow-up fixes from codex review on PR #940:
P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.
Fix:
- infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
a structurally-shared helper that drops fontFamily and clears
fontFamilyOverride when the id is deprecated.
- sanitizeHost now runs it on every host load.
- domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
applies it both on initial load and on cross-tab storage events.
- Existing decrypt → sanitize → encrypt round-trip in useVaultState
means the migrated values are persisted back to localStorage and
propagate through cloud sync naturally.
Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.
P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.
OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): also sanitize group configs on the write/import path
Addresses codex P2 review on PR #940:
https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx
The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.
Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): migrate deprecated terminal font ids on every ingest path
Addresses codex P2 review on PR #940:
https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx
The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:
- rehydrateAllFromStorage() at line ~527 — runs on remote-import
completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
- The notifySettingsChanged IPC handler at line ~663 — fires when a
cloud sync or programmatic localStorage write announces a change.
- The cross-window storage event handler at line ~873.
Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.
Centralizes the migration in migrateIncomingTerminalFontId(raw):
- returns null when raw is empty
- if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
localStorage AND returns it
- otherwise returns raw unchanged
All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): use bundled Latin-only fallback instead of monospace generic
Resolves the tension between codex's two P1 reviews on PR #940:
Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
otherwise Latin glyphs fall into a CJK font's full-width Latin
when the primary font is missing.
Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
otherwise on macOS Chrome, the generic `monospace` pulls in
PingFang via Chromium's CJK system fallback and silently masks
the user's CJK picker.
Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.
Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.
New stack order:
<primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
<system-cjk-stack>, <nerd-font-stack>, monospace
Per-glyph CSS fallback now behaves as intended on every platform:
- Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
- CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
- Nerd PUA: all of the above → Nerd Font stack.
Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): use OR-of-fallbacks for canvas font detection
Addresses codex P2 review on PR #940:
https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx
detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.
Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.
Regression tests added for both directions:
- Menlo with metrics identical to `monospace` generic → installed.
- "Definitely Not Installed" font → still reported missing.
The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): quote-aware tokenizer for font-family lists
Addresses codex P2 review on PR #940:
https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx
composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.
No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.
Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.
Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(fonts): translate Layer 3 CJK font descriptions to English
The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.
Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.
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 line printed once per terminal session and offered no diagnostic
value beyond what window.__xtermRenderer already exposes for ad-hoc
introspection. Keep the detection + retry + window publish; just
stop polluting the console. Rename logRenderer → trackRenderer to
match the now-narrowed responsibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hook returned a fresh object literal every render. The 26 methods
inside were already useCallback([])-stable, but the wrapping object
was not — so every consumer's effect with `terminalBackend` in deps
(e.g. cwd polling, lifecycle wiring, write-to-session) re-ran on
every parent render even though nothing semantic had changed, and
ESLint flagged the one site that depended on a property access
(`terminalBackend.onHostKeyVerification`) because it could not prove
that path safe.
Wrap the return in useMemo with all stable callbacks listed as deps
so the object is computed once and cached for the hook's lifetime.
Switch the host-key-verification effect's dep to the now-stable
`terminalBackend`, clearing the warning at the root rather than
patching it locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The reliability gate at handleInput's adjustToInput call froze the
ghost at its last show()-time tail in any path where the typed buffer
becomes unreliable (Tab pass-through to shell, history recall, cursor
moves). When the user kept typing into that gap, the next render
advanced the cursor past the ghost's anchor while the ghost text
stayed put — a → -accept then pasted the stale tail on top of the
just-typed glyphs (e.g. "systemctl s" + typing "t" → screen showed
"systemctl sttop firewalld").
Add GhostTextAddon.applyKeystroke so the ghost can evolve its own
currentInput off raw keystrokes (printable / Backspace / Ctrl-W),
seeded by whatever the last show() captured from the live xterm
reading. handleInput now uses the existing adjustToInput on the
reliable path (preserves multi-char paste re-alignment) and routes
single-keystroke events through applyKeystroke on the unreliable
path, fixing the visual misalignment and the duplication-on-accept
in one shot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExternalAgentConfig.command/acpCommand/args/env are OS- and
machine-specific (binary paths, .exe suffixes, platform-dependent
environment values). Pushing them to other devices either fails to
resolve or silently runs the wrong thing.
Stop collecting/applying STORAGE_KEY_AI_EXTERNAL_AGENTS and remove the
field from the SyncPayload type. apply silently ignores the field on
legacy snapshots that still carry it, so existing remote data is safe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`collectSyncableSettings` strips device-bound encrypted apiKeys from
provider entries and webSearchConfig before upload, but
`applySyncableSettings` was writing them back wholesale, silently wiping
local credentials whenever any other setting changed on a second device.
Merge by id (providers) and by providerId (web search) so a synced
payload only overrides the apiKey when it explicitly carries one.
Also include `application/*.test.ts` in the npm test glob so the
syncPayload tests added in this PR actually run in CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).
Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.
Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
Extend cloud sync to cover AI provider config, external agents,
permission/tool modes, command policy, web search settings,
workspace focus style, terminal follow-app theme, SFTP default view,
and additional terminal options. Device-bound encrypted apiKey
placeholders are stripped from providers and webSearchConfig before
upload. Auto-sync now reacts to syncable localStorage changes via a
new adapter-level event.
Center the Settings window on the display of the window that opened
it instead of always using the main window, fixing issue #920.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The SFTP file-list "Upload File(s)" context menu items only make sense
on remote panes — local panes have no upload semantic. Plumb a new
`isLocal` prop into SftpPaneFileList and suppress both the menu items
and the hidden file inputs when the active pane is local.
Also add an "Upload Folder..." item alongside "Upload File(s)..." that
opens a `<input type="file" webkitdirectory>` picker. The resulting
FileList is routed through a new `uploadExternalFolder` /
`onUploadExternalFolder` callback that calls `uploadFromFileList`, so
folder structure is preserved via webkitRelativePath without any new
IPC. When invoked from a directory row, the folder is uploaded INTO
that directory (matching drag-and-drop semantics).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#916.
When the user clicks "Restart" after a session disconnects, the
renderer reuses the same sessionId and the bridges call startStream
again to open a fresh log file for the new connection. The previous
connection's close handlers (e.g. SSH conn.once('close'),
stream.on('close'), serial 'close', telnet 'close', mosh PTY exit)
all still fire asynchronously and call stopStream(sessionId)
unconditionally. If they land after the new stream is already
active, they silently destroy it and subsequent terminal output for
the reconnected session is dropped, matching the bug report where
the first connection's IO is saved but the reconnect's is not.
Make startStream return a unique token and require stopStream
callers to pass it. A stale stop call carrying the previous
incarnation's token is now a no-op, so a late close handler from
the previous connection cannot kill the freshly-started stream.
Each reconnect therefore produces its own timestamped log file,
which mirrors the existing auto-save-on-close semantics and is the
simpler of the two options the issue offered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Provides a discoverable entry point to the Settings panel for users
who don't use the Cmd/Ctrl+, hotkey. Sits at the right edge of the
title bar on macOS and immediately to the left of the custom window
controls on Windows/Linux. Reuses the existing onOpenSettings prop
already wired through from App.tsx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right-click on an SFTP pane now offers an "Upload File(s)" menu item
that opens a native multi-file picker, so users no longer have to drag
and drop to upload (issue #915). Selected files are wrapped in a
DataTransfer and dispatched through the existing onUploadExternalFiles
pipeline; right-clicking a directory uploads into that folder. Folder
upload via the picker is intentionally out of scope.
Fixes#915
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
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>
apple_silicon:`[](${baseUrl}/${files.mac.arm64})`,
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
);
}
if (body.length < 120) {
errors.push(
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
- `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
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install dependencies
npm install
# Start dev server (runs lint first, then Vite + Electron concurrently)
npm run dev
# Lint
npm run lint
npm run lint:fix
# Run all tests
npm test
# Run a single test file
node --test --import tsx path/to/file.test.ts
# Build renderer
npm run build
# Package for current platform
npm run pack
# Package for specific platforms
npm run pack:mac
npm run pack:win
npm run pack:linux
```
## Architecture
Netcatty is an Electron + React desktop app (SSH manager, terminal, SFTP browser). It has two runtimes:
### Electron Main Process (`electron/`)
- **`main.cjs`** — entry point; wires crash logging, process error guards, and delegates to `main/registerBridges.cjs`
- **`bridges/`** — one `.cjs` file per capability domain (sshBridge, sftpBridge, terminalBridge, portForwardingBridge, aiBridge, etc.). Each bridge exposes IPC handlers via `ipcMain`. Tests live alongside the bridge file (`*.test.cjs`).
- **`preload.cjs`** — exposes a typed `window.electron` API to the renderer via `contextBridge`. Uses `preload/api.cjs` for the generated API surface.
- **`cli/`** — `netcatty-tool-cli.cjs` is a separate internal binary for tool/MCP integration; treat as internal surface only.
### Renderer Process (React + Vite)
Three-layer architecture (see `AGENTS.md` for full detail):
- **`domain/`** — pure TypeScript logic, no side effects. Models (`models.ts`), host helpers, workspace tree operations.
- **`application/state/`** — React hooks that own state and persistence boundaries. Key hooks: `useVaultState` (hosts/keys/snippets), `useSessionState` (terminal sessions/workspace), `useSettingsState` (theme/config).
- **`infrastructure/`** — external edges: `persistence/localStorageAdapter.ts` for storage, `services/` for network calls (Gemini AI, GitHub Gist sync), `config/` for defaults, storage keys, and terminal themes.
- **`components/`** — presentation only. `App.tsx` wires hooks to components; no business logic in components.
### IPC Pattern
UI calls `window.electron.*` (preload API) → IPC → bridge handler in main process. Never call `ipcRenderer` directly from components.
### Key Conventions
- All storage reads/writes go through `localStorageAdapter`; storage keys are in `infrastructure/config/storageKeys.ts`.
- Temporary files must use `tempDirBridge.getTempFilePath(fileName)` — never `os.tmpdir()` directly.
- Aside panels (VaultView subpages) use the shared design system in `components/ui/aside-panel.tsx` — see `AGENTS.md` for usage patterns.
- Renderer code is TypeScript/ESM; Electron main/bridges are CommonJS (`.cjs`).
- Path alias `@/` resolves to the repo root (configured in `vite.config.ts` and `tsconfig.json`).
> 🚀 **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.
'ai.providers.style.help':'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.codex.description':'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting':'Detecting...',
'ai.codex.notFound':'Not found',
'ai.codex.awaitingLogin':'Awaiting login',
'ai.codex.connectedChatGPT':'Connected via ChatGPT',
'ai.codex.connectedApiKey':'Connected via API key',
'ai.codex.connectedCustomConfig':'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete':'Custom config detected (env var missing)',
'ai.codex.customConfigHint':'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey':'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected':'Not connected',
'ai.codex.statusUnknown':'Status unknown',
'ai.codex.path':'Path:',
'ai.codex.notFoundHint':'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.claude.configDir.placeholder':'~/.claude (leave blank for default)',
'ai.claude.configDir.hint':'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.settings':'Settings file',
'ai.claude.settings.placeholder':'~/team-settings.json (path, or inline {"model":"..."})',
'ai.claude.settings.hint':'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
'ai.claude.envVars.hint':'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check':'Check',
// AI GitHub Copilot CLI
'ai.copilot.title':'GitHub Copilot CLI',
'ai.copilot.description':'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting':'Detecting...',
'ai.copilot.detected':'Detected',
'ai.copilot.notFound':'Not found',
'ai.copilot.path':'Path:',
'ai.copilot.notFoundHint':'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.codebuddy.description':'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
'ai.codebuddy.detecting':'Detecting...',
'ai.codebuddy.detected':'Detected',
'ai.codebuddy.notFound':'Not found',
'ai.codebuddy.path':'Path:',
'ai.codebuddy.notFoundHint':'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.envVars.hint':'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI Default Agent
'ai.defaultAgent':'Default Agent',
'ai.defaultAgent.description':'Agent to use when starting a new AI session',
'ai.defaultAgent.catty':'Catty (Built-in)',
'ai.toolAccess.title':'Tool Access',
'ai.toolAccess.mode':'Netcatty Access Mode',
'ai.toolAccess.description':'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp':'MCP',
'ai.toolAccess.mode.skills':'Skills + CLI',
'ai.userSkills.title':'User Skills',
'ai.userSkills.description':'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder':'Open Skills Folder',
'ai.userSkills.reload':'Reload Skills',
'ai.userSkills.location':'Location',
'ai.userSkills.loading':'Scanning user skills...',
'ai.userSkills.empty':'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable':'User skills are unavailable in this environment.',
'ai.userSkills.status.ready':'Ready',
'ai.userSkills.status.warning':'Warning',
// AI Quick Messages
'ai.quickMessages.title':'Quick Messages',
'ai.quickMessages.description':'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
'ai.chat.loadMoreSessions':'Load more sessions ({n} more)',
'ai.chat.noSessions':'No previous sessions',
'ai.chat.retryHint':'You can retry by sending your message again.',
'ai.chat.approvalTimeout':'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts':'Hosts',
'ai.chat.menuContext':'Context',
'ai.chat.menuFiles':'Files',
'ai.chat.menuImage':'Image',
'ai.chat.menuMentionHost':'Mention Host',
'ai.chat.menuUserSkills':'User Skills',
'ai.chat.menuSlashCommands':'Slash Commands',
'ai.chat.slashCommands':'Slash commands',
'ai.chat.slashQuickMessages':'Quick messages',
'ai.chat.slashUserSkills':'User skills',
'ai.chat.quickMessages':'Slash commands',
'ai.chat.slashNoResults':'No matching commands',
'ai.chat.slashEmptyHint':'Add prompts in Settings → AI → Quick Messages.',
// AI Chat Shortcuts
'ai.chatShortcuts.title':'Chat Shortcuts',
'ai.chatShortcuts.selectionAction':'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description':'Show a small AI button next to selected terminal text.',
// AI Error
'ai.codex.bridgeError':'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title':'Web Search',
'ai.webSearch.enable':'Enable Web Search',
'ai.webSearch.enable.description':'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider':'Search Provider',
'ai.webSearch.provider.description':'Choose a web search API provider.',
'ai.webSearch.apiKey':'API Key',
'ai.webSearch.apiKey.description':'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder':'Enter API key...',
'ai.webSearch.apiHost':'API Host',
'ai.webSearch.apiHost.description':'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription':'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults':'Max Results',
'ai.webSearch.maxResults.description':'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title':'Safety',
'ai.safety.permissionMode':'Permission Mode',
'ai.safety.permissionMode.description':'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
'ai.safety.permissionMode.observer':'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm':'Confirm - Ask before actions',
'ai.safety.commandTimeout.description':'Maximum seconds a command can run before being terminated through Netcatty execution.',
'ai.safety.commandTimeout.unit':'sec',
'ai.safety.maxIterations':'Max Iterations',
'ai.safety.maxIterations.description':'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist':'Command Blocklist',
'ai.safety.blocklist.description':'Regex patterns to block dangerous commands executed through Netcatty.',
'ai.safety.note':'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal':'Add Terminal',
'terminal.layer.switchToSplitView':'Switch to Split View',
'terminal.layer.sftp':'SFTP',
'terminal.layer.scripts':'Scripts',
'terminal.layer.history':'History',
'terminal.layer.theme':'Theme',
'terminal.layer.aiChat':'AI Chat',
'terminal.layer.movePanelLeft':'Move panel to left',
'terminal.layer.movePanelRight':'Move panel to right',
'credentials.protectionUnavailable.message':'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'settings.system.tempDirectoryHint':'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.unknown':'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint':'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint':'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title':'Crash Logs',
'settings.system.crashLogs.description':'View error logs from the main process to help diagnose unexpected behavior.',
'settings.vault.showRecentHostsDesc':'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot':'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc':'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab':'Show SFTP tab',
'settings.vault.showSftpTabDesc':'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar':'Show host list sidebar',
'settings.vault.showHostTreeSidebarDesc':'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
// Update notifications
'update.available.title':'Update Available',
'update.available.message':'A new version {version} is available. Click to download.',
'update.checking':'Checking for updates...',
'update.upToDate.title':'Up to Date',
'update.upToDate.message':'You are running the latest version ({version}).',
'update.error':'Failed to check for updates',
'update.downloadNow':'Download Now',
'update.viewInSettings':'View in Settings',
'update.readyToInstall.title':'Update Ready',
'update.readyToInstall.message':'Version {version} downloaded and ready to install.',
'update.restartNow':'Restart Now',
'update.downloadFailed.title':'Update Failed',
'update.downloadFailed.message':'Failed to download update. You can download it manually.',
'update.needsSave.title':'Unsaved Changes',
'update.needsSave.message':'Save your open editors first, then click Restart Now again to install the update.',
'update.openReleases':'Open Releases',
'update.remindLater':'Remind Later',
'update.skipVersion':'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme':'UI Theme',
'settings.appearance.theme':'Theme',
'settings.appearance.theme.desc':'Choose light, dark, or follow system preference',
'settings.appearance.windowOpacity.desc':'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.terminal.behavior.copyOnSelect':'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc':'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
'settings.terminal.startupCommandDelay.desc':'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.localShell.shell.customArgs.desc':'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
'settings.terminal.connection.keepaliveInterval.desc':'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax.desc':'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.rendering.renderer.desc':'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc':'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'sync.autoSync.inspectFailedMessage':'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
'sync.autoSync.syncedTitle':'Synced from cloud',
'sync.autoSync.syncedMessage':'Your data has been updated from the cloud.',
'sync.autoSync.noProvider':'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
'sync.autoSync.alreadySyncing':'Sync is already in progress.',
'sync.autoSync.restoreInProgress':'A vault restore is in progress in another window. Please wait for it to finish.',
'sync.autoSync.interruptedApplyMessage':'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
'sync.autoSync.vaultLocked':'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected':'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed':'Sync failed',
'sync.autoSync.restoredTitle':'Vault restored',
'sync.autoSync.restoredMessage':'Your vault has been restored from the cloud.',
'sync.autoSync.keptLocalTitle':'Kept local vault',
'sync.autoSync.keptLocalMessage':'Your empty local vault was kept. Cloud data was not applied.',
'sync.autoSync.emptyVaultConflict.description':'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
'sync.autoSync.emptyVaultManual':'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title':'Sync paused',
'sync.blocked.reason.bulkShrink':'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink':'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail':'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton':'Restore from local backup',
'terminal.auth.passphrase.placeholder':'Optional passphrase for the selected private key',
'terminal.auth.certificate':'Certificate',
'terminal.auth.selectKey':'Select Key',
'terminal.auth.noKeysHint':'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave':'Continue & Save',
'terminal.auth.credentialsUnavailable':'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable':'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable':'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword':'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn':'Timeout in {seconds}s',
'cloudSync.localBackups.restoreMissing':'Backup not found.',
'cloudSync.localBackups.protectiveBackupFailed':'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle':'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc':'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
'cloudSync.localBackups.unavailableDesc':'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
'cloudSync.clearLocal.desc':'Reset local version and sync history. Next sync will download from cloud.',
'cloudSync.clearLocal.button':'Clear',
'cloudSync.clearLocal.dialog.title':'Clear Local Vault Data?',
'cloudSync.clearLocal.dialog.desc':'This will reset local version to 0 and clear sync history. Your next sync will download data from the cloud, replacing local data.',
'cloudSync.clearLocal.dialog.cancel':'Cancel',
'cloudSync.clearLocal.dialog.confirm':'Clear Local Data',
'cloudSync.clearLocal.toast.title':'Local data cleared',
'cloudSync.clearLocal.toast.desc':'Local version reset to 0. Sync to download from cloud.',
'vault.import.sshConfig.managedSuccess':'Imported {count} hosts. File is now managed.',
'vault.import.sshConfig.alreadyManaged':'This file is already being managed.',
'vault.import.sshConfig.alreadyManagedDesc':'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
'vault.import.sshConfig.noFilePath':'Cannot manage this file.',
'vault.import.sshConfig.noFilePathDesc':'Unable to determine the file path. Managed sync requires access to the file system.',
// Known Hosts
'knownHosts.search.placeholder':'Search known hosts...',
'knownHosts.action.scanSystem':'Scan System',
'knownHosts.action.importFile':'Import File',
'knownHosts.action.browseFile':'Browse File',
'knownHosts.empty.title':'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable':'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile':'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries':'No usable entries found in known_hosts.',
'knownHosts.toast.scanImported':'Imported {count} new hosts.',
'knownHosts.toast.scanNoNew':'No new hosts found.',
'knownHosts.toast.scanFailed':'Failed to scan system known_hosts.',
// Port Forwarding
'pf.empty.title':'Set up port forwarding',
'pf.empty.desc':'Save port forwarding to access databases, web apps, and other services.',
'settings.sftp.transferConcurrency.desc':'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.showHiddenFiles.desc':'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.compressedUpload.enableDesc':'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
'hostDetails.agentForwarding.desc':'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning':'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint':'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.deviceType.desc':'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning':'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.legacyAlgorithms.desc':'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning':'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.skipEcdsaHostKey.desc':'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
'hostDetails.algorithms.advanced.desc':'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
'hostDetails.algorithms.inheritedNotice':'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
'hostDetails.keepalive.override':'Override global keepalive',
'hostDetails.keepalive.desc':'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.disabledHint':'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'ai.providers.contextWindow.help':'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error':'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels':'Обновить модели',
'ai.providers.searchModel':'Искать или ввести ID модели...',
'ai.providers.advancedParams.default':'По умолчанию у провайдера',
// AI Codex
'ai.codex':'Codex',
'ai.codex.title':'Codex CLI',
'ai.codex.description':'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.detecting':'Обнаружение...',
'ai.codex.notFound':'Не найден',
'ai.codex.awaitingLogin':'Ожидание входа',
'ai.codex.connectedChatGPT':'Подключено через ChatGPT',
'ai.codex.connectedApiKey':'Подключено через API-ключ',
'ai.codex.connectedCustomConfig':'Подключено через ~/.codex/config.toml',
'ai.codex.customConfigHint':'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
'ai.codex.customConfigMissingEnvKey':'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите netcatty из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
'ai.codex.notConnected':'Не подключено',
'ai.codex.statusUnknown':'Статус неизвестен',
'ai.codex.path':'Путь:',
'ai.codex.notFoundHint':'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.claude.configSection':'Аутентификация и конфигурация (опционально)',
'ai.claude.configDir':'Каталог конфигурации',
'ai.claude.configDir.placeholder':'~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint':'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.settings':'Файл настроек',
'ai.claude.settings.placeholder':'~/team-settings.json (путь или встроенный {"model":"..."})',
'ai.claude.settings.hint':'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
'ai.claude.envVars.hint':'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check':'Проверить',
// AI GitHub Copilot CLI
'ai.copilot.title':'GitHub Copilot CLI',
'ai.copilot.description':'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
'ai.copilot.detecting':'Обнаружение...',
'ai.copilot.detected':'Обнаружен',
'ai.copilot.notFound':'Не найден',
'ai.copilot.path':'Путь:',
'ai.copilot.notFoundHint':'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.cursor.apiKeyPlaceholder.env':'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint':'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint':'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.codebuddy.description':'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.codebuddy.detecting':'Обнаружение...',
'ai.codebuddy.detected':'Обнаружен',
'ai.codebuddy.notFound':'Не найден',
'ai.codebuddy.path':'Путь:',
'ai.codebuddy.notFoundHint':'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.envVars.hint':'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
// AI Default Agent
'ai.defaultAgent':'Агент по умолчанию',
'ai.defaultAgent.description':'Агент, который будет использоваться при запуске новой AI-сессии',
'ai.defaultAgent.catty':'Catty (встроенный)',
'ai.toolAccess.title':'Доступ к инструментам',
'ai.toolAccess.mode':'Режим доступа Netcatty',
'ai.toolAccess.description':'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp':'MCP',
'ai.toolAccess.mode.skills':'Skills + CLI',
'ai.userSkills.title':'Пользовательские skills',
'ai.userSkills.description':'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
'ai.userSkills.empty':'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
'ai.userSkills.unavailable':'Пользовательские skills недоступны в этой среде.',
'ai.userSkills.status.ready':'Готово',
'ai.userSkills.status.warning':'Предупреждение',
// AI Quick Messages
'ai.quickMessages.title':'Быстрые сообщения',
'ai.quickMessages.description':'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
'ai.chat.slashEmptyHint':'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Chat Shortcuts
'ai.chatShortcuts.title':'Быстрые действия чата',
'ai.chatShortcuts.selectionAction':'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description':'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
// AI Error
'ai.codex.bridgeError':'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
// AI Web Search
'ai.webSearch.title':'Веб-поиск',
'ai.webSearch.enable':'Включить веб-поиск',
'ai.webSearch.enable.description':'Разрешить AI-агенту искать в интернете актуальную информацию.',
'ai.webSearch.provider':'Провайдер поиска',
'ai.webSearch.provider.description':'Выберите провайдера API веб-поиска.',
'ai.webSearch.apiKey':'API-ключ',
'ai.webSearch.apiKey.description':'API-ключ для выбранного провайдера поиска.',
'ai.webSearch.maxResults':'Макс. число результатов',
'ai.webSearch.maxResults.description':'Максимальное количество результатов поиска для возврата (1-20).',
// AI Safety Settings
'ai.safety.title':'Безопасность',
'ai.safety.permissionMode':'Режим разрешений',
'ai.safety.permissionMode.description':'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
'ai.safety.permissionMode.observer':'Наблюдатель — только чтение, без действий',
'ai.safety.permissionMode.confirm':'Подтверждение — спрашивать перед действиями',
'ai.safety.permissionMode.autonomous':'Автономный — выполнять свободно',
'ai.safety.commandTimeout':'Тайм-аут команды',
'ai.safety.commandTimeout.description':'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
'ai.safety.commandTimeout.unit':'с',
'ai.safety.maxIterations':'Макс. число итераций',
'ai.safety.maxIterations.description':'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.blocklist':'Чёрный список команд',
'ai.safety.blocklist.description':'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
'ai.safety.blocklist.reset':'Сбросить по умолчанию',
'ai.safety.blocklist.add':'Добавить шаблон',
'ai.safety.note':'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal':'Добавить терминал',
'terminal.layer.switchToSplitView':'Переключить в режим разделения',
'placeholder.workspaceName':'Имя рабочего пространства',
'placeholder.sessionName':'Имя сессии',
'placeholder.searchHosts':'Поиск хостов...',
'toast.settingsUnavailable':'Окно настроек недоступно на этой платформе.',
'credentials.protectionUnavailable.title':'Защита учётных данных недоступна',
'credentials.protectionUnavailable.message':'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'settings.system.tempDirectoryHint':'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.credentials.unknown':'Неизвестно (не поддерживается в этой среде)',
'settings.system.credentials.unavailableHint':'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint':'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
'settings.sshDebugLogs.hint':'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
'settings.globalHotkey.enabledDesc':'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
'settings.globalHotkey.hint':'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
// Tray Panel
'tray.openMainWindow':'Открыть главное окно',
'tray.sessions':'Сессии',
'tray.portForwarding':'Проброс портов',
'tray.status.connected':'Подключено',
'tray.status.connecting':'Подключение',
'tray.status.disconnected':'Отключено',
'tray.status.active':'Активно',
'tray.status.inactive':'Неактивно',
'tray.status.error':'Ошибка',
'tray.recentHosts':'Недавние хосты',
'tray.empty.title':'Пока здесь ничего нет',
'tray.empty.subtitle':'Подключитесь к серверу, они по вам скучают 🚀',
'settings.application.whatsNew.subtitle':'Показать примечания к релизу',
'settings.application.openExternal.failedTitle':'Не удалось открыть ссылку',
'settings.application.openExternal.failedBody':'Не удалось открыть ссылку ни в системном браузере, ни во встроенном окне браузера.',
'settings.vault.title':'Хранилище',
'settings.vault.showRecentHosts':'Показывать недавно подключённые хосты',
'settings.vault.showRecentHostsDesc':'Показывать раздел недавно подключённых хостов в верхней части хранилища',
'settings.vault.showOnlyUngroupedHostsInRoot':'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc':'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTabDesc':'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language':'Язык',
'settings.appearance.language.desc':'Выберите язык интерфейса',
'settings.appearance.uiFont':'Шрифт интерфейса',
'settings.appearance.uiFont.desc':'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity.desc':'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.terminal.behavior.copyOnSelect':'Копировать при выделении',
'settings.terminal.behavior.copyOnSelect.desc':'Автоматически копировать выделенный текст. В tmux/vim с режимом мыши удерживайте Option на macOS или Shift на Windows/Linux для выделения',
'settings.terminal.behavior.middleClickPaste':'Вставка средней кнопкой мыши',
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.startupCommandDelay.label':'Задержка команды запуска (мс)',
'settings.terminal.startupCommandDelay.desc':'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
'settings.terminal.localShell.shell.desc':'Путь к исполняемому файлу оболочки (например, /bin/zsh, pwsh.exe). Оставьте пустым, чтобы использовать системную оболочку по умолчанию.',
'settings.terminal.localShell.shell.placeholder':'Системная по умолчанию',
'settings.terminal.localShell.shell.customArgs.desc':'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
'settings.terminal.localShell.startDir.desc':'Каталог, в котором будет открываться локальный терминал. Оставьте пустым, чтобы использовать домашний каталог.',
'settings.terminal.connection.keepaliveInterval.desc':'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax':'Макс. число пропущенных keepalive',
'settings.terminal.connection.keepaliveCountMax.desc':'Количество пропущенных keepalive, после которого соединение считается мёртвым. Более высокие значения лучше переносят краткие сетевые сбои и медленные ответы SSH-серверов.',
'settings.terminal.connection.x11Display.desc':'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder':'Авто (:0 или DISPLAY)',
'settings.terminal.rendering.renderer.desc':'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.shortcuts.disableTerminalFontZoom.desc':'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label':'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc':'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'sync.autoSync.inspectFailedMessage':'Не удалось подключиться к облаку для проверки изменений. Автосинхронизация повторит попытку при изменении данных или после перезапуска приложения.',
'sync.autoSync.syncedTitle':'Синхронизировано из облака',
'sync.autoSync.syncedMessage':'Ваши данные были обновлены из облака.',
'sync.autoSync.noProvider':'Облачный провайдер не подключён. Откройте Настройки → Синхронизация и облако, чтобы подключить его.',
'sync.autoSync.alreadySyncing':'Синхронизация уже выполняется.',
'sync.autoSync.restoreInProgress':'В другом окне уже выполняется восстановление хранилища. Подождите, пока оно завершится.',
'sync.autoSync.interruptedApplyTitle':'Синхронизация приостановлена — предыдущее восстановление прервано',
'sync.autoSync.interruptedApplyMessage':'Предыдущее восстановление завершилось некорректно, поэтому локальное хранилище может быть в несогласованном состоянии. Откройте Настройки → Синхронизация и облако → Восстановление и примените защитную резервную копию перед возобновлением автосинхронизации.',
'sync.autoSync.vaultLocked':'Хранилище заблокировано. Откройте Настройки → Синхронизация и облако, чтобы разблокировать его.',
'sync.autoSync.conflictDetected':'Обнаружен конфликт синхронизации. Откройте Настройки → Синхронизация и облако, чтобы разрешить его.',
'sync.autoSync.syncFailed':'Синхронизация не удалась',
'sync.autoSync.emptyVaultConflict.description':'Ваше локальное хранилище пусто, но в облаке есть данные. Обычно это происходит после обновления или сброса хранилища. Что вы хотите сделать?',
'sync.autoSync.emptyVaultManual':'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
'sync.blocked.reason.bulkShrink':'Будет удалено {lost} из {baseCount} сущностей типа {entityType} из облака (сокращение на {percent}%).',
'sync.blocked.reason.largeShrink':'Будет удалено {lost} сущностей типа {entityType} из облака.',
'sync.blocked.detail':'Обычно это вызвано повреждённым локальным состоянием (сбой keychain, частичная загрузка данных). Восстановите данные из локальной резервной копии или выполните принудительную отправку, если вы действительно хотели удалить эти записи.',
'sync.blocked.restoreButton':'Восстановить из локальной резервной копии',
'sync.blocked.forcePushButton':'Всё равно отправить принудительно',
'sync.credentialsUnavailable':'Это устройство не может расшифровать некоторые сохранённые учётные данные. Перед синхронизацией повторно введите их локально.',
'vault.groups.deleteDialog.desc':'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
'vault.groups.deleteDialog.managedDesc':'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
'vault.groups.deleteDialog.deleteHosts':'Также удалить все хосты в этой группе',
'terminal.auth.passphrase.placeholder':'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate':'Сертификат',
'terminal.auth.selectKey':'Выбрать ключ',
'terminal.auth.noKeysHint':'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave':'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable':'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
'terminal.auth.jumpCredentialsUnavailable':'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
'terminal.auth.proxyCredentialsUnavailable':'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
'terminal.auth.keyUnavailableFallbackPassword':'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
'terminal.progress.timeoutIn':'Тайм-аут через {seconds}с',
'terminal.progress.disconnected':'Отключено',
'terminal.progress.cancelling':'Отмена...',
'terminal.progress.startOver':'Начать заново',
'terminal.connection.dismissDisconnectedDialog':'Закрыть уведомление об отключении',
'terminal.connection.chainOf':'Цепочка {current} из {total}',
'cloudSync.gate.title':'Синхронизация с end-to-end шифрованием',
'cloudSync.gate.desc':
'Ваши данные шифруются локально перед синхронизацией. Облачные провайдеры никогда не видят ваши данные в открытом виде. Задайте мастер-ключ, чтобы включить безопасную синхронизацию.',
'cloudSync.strategy.desc':'Выберите, что делать, когда изменились и локальные, и облачные данные.',
'cloudSync.strategy.smartMerge':'Умное объединение (рекомендуется)',
'cloudSync.strategy.smartMergeDesc':'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
'cloudSync.localBackups.restoreMissing':'Резервная копия не найдена.',
'cloudSync.localBackups.protectiveBackupFailed':'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle':'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc':'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
'cloudSync.localBackups.unavailableDesc':'Эта платформа не предоставляет Netcatty безопасное хранилище ключей, поэтому локальные резервные копии нельзя записывать безопасно. Установите Netcatty в систему с поддерживаемым keychain, чтобы включить историю локальных резервных копий.',
'cloudSync.localBackups.lockedDesc':'Настройте или разблокируйте мастер-ключ перед восстановлением резервной копии, чтобы восстановленные учётные данные оставались зашифрованными.',
'cloudSync.revisionHistory.viewButton':'История',
'cloudSync.revisionHistory.title':'История версий хранилища',
'cloudSync.revisionHistory.description':'Просматривайте и восстанавливайте предыдущие версии вашего хранилища из истории ревизий Gist.',
'cloudSync.revisionHistory.empty':'Ревизии не найдены.',
'cloudSync.clearLocal.desc':'Сбросить локальную версию и историю синхронизации. При следующей синхронизации данные будут скачаны из облака.',
'cloudSync.clearLocal.button':'Очистить',
'cloudSync.clearLocal.dialog.title':'Очистить локальные данные хранилища?',
'cloudSync.clearLocal.dialog.desc':'Локальная версия будет сброшена до 0, а история синхронизации очищена. При следующей синхронизации данные будут скачаны из облака и заменят локальные.',
'snippets.search.noResults.desc':'Ни один сниппет или пакет не соответствует запросу "{query}". Попробуйте другой поисковый запрос или очистите поиск для просмотра.',
'vault.import.sshConfig.managedSuccess':'Импортировано {count} хостов. Файл теперь находится под управлением.',
'vault.import.sshConfig.alreadyManaged':'Этот файл уже находится под управлением.',
'vault.import.sshConfig.alreadyManagedDesc':'Этот файл уже управляется в группе "{group}". Если хотите импортировать его заново, сначала удалите существующий управляемый источник.',
'vault.import.sshConfig.noFilePath':'Невозможно управлять этим файлом.',
'vault.import.sshConfig.noFilePathDesc':'Не удалось определить путь к файлу. Для управляемой синхронизации нужен доступ к файловой системе.',
// Known Hosts
'knownHosts.search.placeholder':'Поиск известных хостов...',
'settings.sftp.transferConcurrency.desc':'Количество файлов, передаваемых параллельно при загрузке или скачивании папок. Более высокие значения могут ускорить работу, но способны перегрузить некоторые серверы.',
'settings.sftp.defaultOpener':'Приложение для открытия по умолчанию',
'settings.sftp.defaultOpener.desc':'Выберите приложение по умолчанию для открытия файлов без конкретной ассоциации',
'settings.sftp.doubleClickBehavior.transfer':'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc':'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc':'Передать файл на активный хост другой панели',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync':'Автосинхронизация с удалённым сервером',
'settings.sftp.autoSync.desc':'Автоматически синхронизировать изменения файлов обратно на удалённый сервер при открытии файлов во внешних приложениях',
'settings.sftp.autoOpenSidebar.enableDesc':'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
'settings.sftp.followTerminalCwd':'Следовать за каталогом терминала',
'settings.sftp.followTerminalCwd.desc':'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
'settings.sftp.followTerminalCwd.enable':'Включать следование по умолчанию',
'settings.sftp.followTerminalCwd.enableDesc':'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
'settings.sftp.defaultViewMode':'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc':'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
'settings.sftp.defaultViewMode.list':'Список',
'settings.sftp.defaultViewMode.listDesc':'Показывать файлы в виде плоского списка для текущего каталога',
'settings.sftp.defaultViewMode.tree':'Дерево',
'settings.sftp.defaultViewMode.treeDesc':'Показывать файлы в иерархической древовидной структуре',
'sftp.autoSync.success':'Файл синхронизирован с удалённым сервером: {fileName}',
'sftp.autoSync.error':'Не удалось синхронизировать файл: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress':'Загрузка файлов {current} из {total}...',
'sftp.upload.uploading':'Загрузка...',
'sftp.upload.compressing':'Сжатие...',
'sftp.upload.extracting':'Распаковка...',
'sftp.upload.scanning':'Сканирование файлов...',
'sftp.upload.completed':'Завершено',
'sftp.upload.compressed':'Сжатая передача',
'sftp.upload.currentFile':'Текущий: {fileName}',
'sftp.upload.cancelled':'Загрузка отменена',
'sftp.upload.cancel':'Отмена',
'sftp.upload.completedToPath':'Загружено в {path}',
// SFTP Download
'sftp.download.completed':'Скачано',
'sftp.download.cancelled':'Скачивание отменено',
// SFTP Reconnecting
'sftp.reconnecting.title':'Переподключение...',
'sftp.reconnecting.desc':'Соединение потеряно, выполняется попытка переподключения',
'sftp.reconnected':'Соединение восстановлено',
'sftp.error.reconnectFailed':'Не удалось переподключиться. Попробуйте ещё раз.',
'settings.sftp.showHiddenFiles.desc':'Показывать скрытые файлы (dotfiles в Unix/macOS и файлы с атрибутом hidden в Windows) в файловом браузере SFTP.',
'settings.sftp.compressedUpload.enableDesc':'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
// Quick Switcher
'qs.search.placeholder':'Поиск хостов или вкладок',
'hostDetails.agentForwarding.agentNotRunningHint':'SSH Agent не обнаружен. Включите OpenSSH Authentication Agent в службах Windows или используйте совместимый агент, например Bitwarden, 1Password или gpg-agent.',
'hostDetails.deviceType.desc':'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
'hostDetails.deviceType.warning':'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.legacyAlgorithms.desc':'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning':'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
'hostDetails.skipEcdsaHostKey.desc':'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
'hostDetails.algorithms.advanced.desc':'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
'hostDetails.algorithms.inheritedNotice':'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
'hostDetails.keepalive.desc':'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
'hostDetails.keepalive.countMax':'Макс. число пропущенных keepalive',
'hostDetails.keepalive.disabledHint':'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
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.