Compare commits

..

51 Commits

Author SHA1 Message Date
bincxz
df11beff8c fix: clear mainWindow reference on window destroy (#587)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
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>
2026-03-31 19:16:59 +08:00
陈大猫
c14da33e5b Merge pull request #588 from binaricat/fix/settings-window-title
fix: settings window title and dock reopen behavior
2026-03-31 19:11:37 +08:00
bincxz
f1ce541885 fix: dock click opens main window instead of settings window (#587)
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>
2026-03-31 19:05:13 +08:00
bincxz
07e003fe43 fix: distinguish settings window title from main window
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>
2026-03-31 19:02:36 +08:00
陈大猫
81f53c9a7f Merge pull request #585 from binaricat/feat/always-immersive-mode
feat: enable immersive mode permanently
2026-03-31 16:25:57 +08:00
bincxz
2d8cea2e7d fix: remove stale immersive mode sync/rehydration handlers
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>
2026-03-31 15:37:59 +08:00
bincxz
b724cfc775 feat: enable immersive mode permanently and remove settings toggle
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>
2026-03-31 15:29:12 +08:00
bincxz
10ff2cc092 ui: increase unfocused workspace terminal opacity from 0.65 to 0.82
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:59 +08:00
bincxz
4124c03b80 fix: maintain scroll position when terminal search bar opens/closes
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>
2026-03-31 14:39:16 +08:00
bincxz
56a3994a52 fix: prevent tab indicator line color flash during theme switching
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>
2026-03-31 14:21:14 +08:00
陈大猫
e1e730e439 Merge pull request #584 from binaricat/feat/expand-builtin-themes
feat: add 12 new built-in terminal color themes
2026-03-31 14:15:51 +08:00
bincxz
bb17647954 feat: add 12 new built-in terminal color themes
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>
2026-03-31 13:51:03 +08:00
bincxz
56a0baebeb ui: use accent color for active tab indicator and remove toolbar border
- 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>
2026-03-31 13:41:01 +08:00
bincxz
d2a6c67e4e refactor: extract shared ThemeList component for theme selection UI
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>
2026-03-31 13:10:22 +08:00
bincxz
56f70d015d ui: optimize host details and chain panel layout
- 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>
2026-03-31 12:57:17 +08:00
陈大猫
cf9f84767c Merge pull request #583 from binaricat/feat/show-transport-error-in-disconnect-dialog
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: show transport error in disconnect dialog
2026-03-31 10:41:25 +08:00
bincxz
3a862cbd0c feat: show transport error in disconnect dialog
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>
2026-03-31 10:37:42 +08:00
陈大猫
6af2a99680 Merge pull request #582 from binaricat/fix/ssh-keepalive-disabled-not-honored
fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
2026-03-31 10:32:06 +08:00
bincxz
b3d37d134a fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
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>
2026-03-31 10:27:08 +08:00
bincxz
a9e561ee51 feat: show "Waiting for remote..." during ZMODEM upload finalization
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
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>
2026-03-31 00:43:26 +08:00
bincxz
e808b1709e fix: increase ZMODEM handshake timeout from 10s to 120s
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>
2026-03-31 00:38:45 +08:00
bincxz
d75b58e4d8 fix: timeout on ZMODEM handshake rejects instead of resolving
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>
2026-03-31 00:28:57 +08:00
bincxz
e2430cdcab fix: cancel sentry on all session cleanup paths + upload timeout guard
- 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>
2026-03-31 00:20:07 +08:00
bincxz
8e6ac8de10 revert: remove ZACK ignore handler (caused by SOCKS5 proxy, not protocol)
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>
2026-03-31 00:11:03 +08:00
bincxz
5495877e5a fix: ignore stray ZACK headers during ZMODEM upload
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>
2026-03-31 00:05:15 +08:00
bincxz
5078b3776e fix: use setImmediate instead of setTimeout(50) for drain wait
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>
2026-03-31 00:03:11 +08:00
bincxz
f5d6b8b4d8 fix: add backpressure handling to ZMODEM upload loop
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>
2026-03-30 23:58:42 +08:00
bincxz
1c560dbc16 fix: reject CLI paths that fail --version probe
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>
2026-03-30 23:48:15 +08:00
bincxz
4b8b0ed74c fix: reject .app directories in CLI path normalization
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>
2026-03-30 23:45:58 +08:00
陈大猫
308d825db7 feat: ZMODEM (lrzsz) file transfer support (#579)
* 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>
2026-03-30 23:39:35 +08:00
陈大猫
af074c5704 Merge pull request #578 from binaricat/fix/tool-call-duplicate-and-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve tool call duplication and ordering in chat UI
2026-03-30 19:06:49 +08:00
bincxz
c60afdd8fe fix: preserve approval controls for tool calls in non-last assistant messages
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>
2026-03-30 18:56:28 +08:00
bincxz
a1d05ca5b3 fix: resolve tool call duplication and ordering in chat UI
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>
2026-03-30 18:54:17 +08:00
陈大猫
327ca3806a Merge pull request #577 from tces1/dev
feat: add GitHub Copilot CLI agent support
2026-03-30 18:24:39 +08:00
bincxz
2f71dd3927 revert: don't override copilot acpCommand with resolved path
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>
2026-03-30 18:16:50 +08:00
bincxz
3844edd49f fix: clean up copilot temp dir even when provider init fails
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>
2026-03-30 17:57:00 +08:00
bincxz
8f97a7e81d fix: use resolved path as copilot acpCommand and add Windows home fallback
- 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>
2026-03-30 17:48:07 +08:00
bincxz
5daf1f0d6f fix: hoist copilotConfigInfo above try block to fix ReferenceError
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>
2026-03-30 17:38:39 +08:00
bincxz
b1a5b92ce4 fix: clean up transient copilot temp dirs and remove verbose MCP logs
- 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>
2026-03-30 17:27:18 +08:00
bincxz
c99a70831a fix: address review issues in copilot agent integration
- 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>
2026-03-30 17:22:59 +08:00
bincxz
4b0468b0d2 merge: resolve conflicts with main for copilot agent support
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>
2026-03-30 17:14:45 +08:00
陈大猫
f32078f270 Merge pull request #575 from binaricat/codex/fix-codex-agent-path-and-mcp-startup
[codex] fix codex agent path detection and MCP startup
2026-03-30 17:02:06 +08:00
Eric Chan
a525c073b9 fix: matchesAgentCommand update for windows shim 2026-03-30 16:29:14 +08:00
bincxz
afceb92a55 fix: fall back to PATH search when stored CLI path is stale
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>
2026-03-30 16:27:32 +08:00
bincxz
4822894efb refactor: eliminate circular effect dependency in managed agent consolidation
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>
2026-03-30 16:08:04 +08:00
Eric Chan
d9b51c3a50 feat: add GitHub Copilot CLI agent support 2026-03-30 15:53:08 +08:00
bincxz
15b1dba558 fix stale managed codex path reuse 2026-03-30 15:51:14 +08:00
bincxz
fd6b3930c1 fix codex managed-agent regressions 2026-03-30 15:26:44 +08:00
bincxz
53cb160a6e fix codex agent path detection and MCP startup 2026-03-30 15:04:06 +08:00
陈大猫
bb590f140d Merge pull request #574 from binaricat/fix/autocomplete-click-outside-dismiss
fix: dismiss autocomplete popup on click outside
2026-03-30 11:25:54 +08:00
bincxz
945992b80e fix: dismiss autocomplete popup on click outside
Closes #572

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:23:51 +08:00
42 changed files with 2786 additions and 538 deletions

View File

@@ -1687,6 +1687,17 @@ const en: Messages = {
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). 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.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',

View File

@@ -1694,6 +1694,17 @@ const zhCN: Messages = {
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '通过 ACP over stdio`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',

View File

@@ -33,7 +33,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -340,20 +340,11 @@ export const useSettingsState = () => {
}
}, []);
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (stored === null || stored === '') {
// Persist default so collectSyncableSettings() can include it
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
return true;
}
return stored === 'true';
});
const setImmersiveMode = useCallback((enabled: boolean) => {
setImmersiveModeState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
// Immersive mode is always enabled — the toggle has been removed from settings
const immersiveMode = true;
const setImmersiveMode = useCallback((_enabled: boolean) => {
// no-op: immersive mode is always on
}, []);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
@@ -465,14 +456,6 @@ export const useSettingsState = () => {
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
if (storedImmersive === 'true' || storedImmersive === 'false') {
const val = storedImmersive === 'true';
setImmersiveModeState(val);
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
@@ -625,9 +608,6 @@ export const useSettingsState = () => {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
@@ -849,13 +829,6 @@ export const useSettingsState = () => {
setAutoUpdateEnabled(newValue);
}
}
// Sync immersive mode from other windows
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.immersiveMode) {
setImmersiveModeState(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {

View File

@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AgentModelPreset,
AIPermissionMode,
AISession,
AISessionScope,
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
@@ -425,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
// Ref to read agentModelMap inside the effect without re-triggering it
// when setAgentModel updates the map (avoids double ACP spawn).
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
undefined,
undefined,
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
);
// Per-agent model: recall last selection or use first preset as default
@@ -593,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});

View File

@@ -1263,18 +1263,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
@@ -1286,6 +1288,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
{form.os === "linux" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</Card>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
@@ -1294,113 +1401,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
{form.os === "linux" && (
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
<div className="flex items-start gap-2">
<Globe size={14} className="mt-0.5 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Theme Selection */}
<button
type="button"

View File

@@ -44,6 +44,8 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -500,6 +502,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isVisible,
});
const zmodem = useZmodemTransfer(sessionId);
const zmodemToastedRef = useRef(false);
useEffect(() => {
if (zmodem.active) {
zmodemToastedRef.current = false;
return;
}
if (zmodemToastedRef.current) return;
if (zmodem.error) {
zmodemToastedRef.current = true;
toast.error(zmodem.error, 'ZMODEM');
} else if (zmodem.filename) {
zmodemToastedRef.current = true;
toast.success(
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
'ZMODEM',
);
}
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -1092,6 +1115,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isSearchOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
@@ -1549,7 +1592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
@@ -1963,6 +2006,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
@@ -2047,6 +2091,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
)}
{/* ZMODEM transfer progress indicator */}
{zmodem.active && (
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
<ZmodemProgressIndicator
transferType={zmodem.transferType}
filename={zmodem.filename}
transferred={zmodem.transferred}
total={zmodem.total}
fileIndex={zmodem.fileIndex}
fileCount={zmodem.fileCount}
finalizing={zmodem.finalizing}
onCancel={zmodem.cancel}
/>
</div>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

View File

@@ -1460,9 +1460,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
useEffect(() => {
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
const shouldKeepPreview =
activeSidePanelTab === 'theme' &&
!!previewTargetSessionId &&
panelOpen &&
!!themePreview.targetSessionId &&
!!themePreview.themeId;
@@ -1473,8 +1473,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}

124
components/ThemeList.tsx Normal file
View File

@@ -0,0 +1,124 @@
/**
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalTheme;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
isSelected
? 'bg-primary/10'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
return (
<>
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
)}
</>
);
};

View File

@@ -1,13 +1,10 @@
import React, { useMemo, useState } from 'react';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
import React from 'react';
import {
AsidePanel,
AsidePanelContent,
} from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList';
interface ThemeSelectPanelProps {
open: boolean;
@@ -18,40 +15,6 @@ interface ThemeSelectPanelProps {
showBackButton?: boolean;
}
// Mini terminal preview component
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
theme,
isSelected
}) => {
return (
<div
className={cn(
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
isSelected ? "border-primary" : "border-transparent"
)}
style={{ backgroundColor: theme.colors.background }}
>
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span style={{ color: theme.colors.cyan }}>ls</span>
</div>
<div className="flex gap-0.5 flex-wrap">
<span style={{ color: theme.colors.blue }}>dir/</span>
<span style={{ color: theme.colors.green }}>file</span>
</div>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span
className="inline-block w-1 h-1.5"
style={{ backgroundColor: theme.colors.cursor }}
/>
</div>
</div>
</div>
);
};
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
open,
selectedThemeId,
@@ -60,51 +23,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onBack,
showBackButton = true,
}) => {
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
const customThemes = useCustomThemes();
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
return (
<button
key={theme.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
isSelected
? "bg-primary/10"
: "hover:bg-secondary/50"
)}
onClick={() => onSelect(theme.id)}
onMouseEnter={() => setHoveredThemeId(theme.id)}
onMouseLeave={() => setHoveredThemeId(null)}
>
<TerminalPreview theme={theme} isSelected={isSelected} />
<div className="flex-1 min-w-0">
<div className={cn(
"text-sm font-medium truncate",
isSelected && "text-primary"
)}>
{theme.name}
</div>
{theme.id === 'netcatty-dark' && (
<div className="text-xs text-muted-foreground">Default</div>
)}
{theme.id === 'netcatty-light' && (
<div className="text-xs text-muted-foreground">Light mode</div>
)}
</div>
</button>
);
};
return (
<AsidePanel
open={open}
@@ -116,8 +34,10 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
<AsidePanelContent className="p-0">
<ScrollArea className="h-full">
<div className="py-2">
{/* All themes in a single list */}
{allThemes.map(renderThemeItem)}
<ThemeList
selectedThemeId={selectedThemeId || ''}
onSelect={onSelect}
/>
</div>
</ScrollArea>
</AsidePanelContent>

View File

@@ -522,7 +522,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{activeTabId === session.id && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}
@@ -621,7 +621,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}

View File

@@ -11,6 +11,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'openai'
| 'claude'
| 'anthropic'
@@ -20,7 +21,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'terminal'
| 'plus';
type AgentIconVisual = {
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const iconKey = getAgentIconKey(agent);
const visual = AGENT_ICON_VISUALS[iconKey];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'

View File

@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
isSettingsManagedDiscoveredAgent,
matchesManagedAgentConfig,
} from '../../infrastructure/ai/managedAgents';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
(da) => {
if (isSettingsManagedDiscoveredAgent(da)) {
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
}
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
},
),
[discoveredAgents, externalAgents],
);

View File

@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => {
{/* Pending tool calls from the *last* assistant message are rendered
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))

View File

@@ -112,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpListModels?: (
acpCommand: string,
acpArgs?: string[],
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}

View File

@@ -2,14 +2,15 @@
* Host Chain Sub-Panel
* Panel for configuring SSH jump host chain
*/
import { ArrowDown,Plus,X } from 'lucide-react';
import React from 'react';
import { ArrowDown,Plus,Search,X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
export interface ChainPanelProps {
@@ -38,6 +39,14 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onCancel,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = useMemo(() => {
if (!searchQuery.trim()) return availableHostsForChain;
const q = searchQuery.toLowerCase();
return availableHostsForChain.filter(
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
);
}, [availableHostsForChain, searchQuery]);
return (
<AsidePanel
open={true}
@@ -52,16 +61,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
}
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs text-muted-foreground">
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
</p>
<Button className="w-full h-10" onClick={() => { }}>
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
</Button>
</Card>
<div className="p-4 space-y-4 w-0 min-w-full">
{/* Chain visualization */}
<div className="space-y-2">
{chainedHosts.map((host, index) => (
@@ -73,7 +73,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
)}
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
<Button
variant="ghost"
size="icon"
@@ -110,11 +110,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
{availableHostsForChain.length > 0 && (
<Card className="p-3 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8 text-sm"
/>
</div>
<div className="space-y-1">
{availableHostsForChain.map((host) => (
{filteredHosts.map((host) => (
<button
key={host.id}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
onClick={() => onAddHost(host.id)}
>
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />

View File

@@ -3,55 +3,12 @@
* A modal dialog for selecting terminal themes in settings
*/
import React, { memo, useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../../application/state/customThemeStore';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
import { ThemeList } from '../ThemeList';
interface ThemeSelectModalProps {
open: boolean;
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
}) => {
const { t } = useI18n();
// Group themes by type
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
const customThemes = useCustomThemes();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
{/* Theme List */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
)}
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
/>
</div>
{/* Footer */}

View File

@@ -8,7 +8,7 @@
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
@@ -16,8 +16,12 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
}
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
if (!bridge?.aiResolveCli) return null;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
setResolving(true);
try {
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
}, [setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
isResolvingPath={isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
isResolvingPath={isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- GitHub Copilot CLI Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
</div>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
customPath={copilotCustomPath}
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
className="w-64"
/>
</SettingRow>
</div>

View File

@@ -258,19 +258,6 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.immersiveMode")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.immersiveMode")}
description={t("settings.appearance.immersiveMode.desc")}
>
<Toggle
checked={!!isImmersive}
onChange={() => onToggleImmersive?.()}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">

View File

@@ -0,0 +1,87 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const statusText = isResolvingPath
? t('ai.copilot.detecting')
: found
? t('ai.copilot.detected')
: t('ai.copilot.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.copilot.description')}
</p>
</div>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.copilot.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.copilot.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.copilot.check')}
</Button>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
"object-contain",
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>

View File

@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
acpCommand: "claude-agent-acp",
acpArgs: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -108,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -124,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

View File

@@ -0,0 +1,79 @@
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
import React from 'react';
interface ZmodemProgressIndicatorProps {
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
onCancel: () => void;
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
transferType,
filename,
transferred,
total,
fileIndex,
fileCount,
finalizing,
onCancel,
}) => {
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
return (
<div
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-medium truncate">
{filename || label}{fileInfo}
</span>
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
</div>
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
<div
className="h-full rounded-full transition-all duration-150"
style={{
width: `${percent}%`,
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
}}
/>
</div>
<div className="text-[10px] opacity-50 mt-0.5">
{formatBytes(transferred)} / {formatBytes(total)}
</div>
</div>
<button
onClick={onCancel}
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
title="Cancel transfer (Ctrl+C)"
>
<X className="h-3.5 w-3.5 opacity-60" />
</button>
</div>
);
};

View File

@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
/** Called when user clicks outside the popup to dismiss it */
onDismiss?: () => void;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
};
}, [containerRef, onRequestReposition, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
if (!visible || !onDismiss) return;
const handlePointerDown = (e: PointerEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
onDismiss();
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [visible, onDismiss]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${clampedLeft}px`,

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface ZmodemTransferState {
active: boolean;
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
error: string | null;
}
const initialState: ZmodemTransferState = {
active: false,
transferType: null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
finalizing: false,
error: null,
};
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
if (!bridge?.onZmodemEvent) return;
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
switch (event.type) {
case 'detect':
setState({
active: true,
transferType: event.transferType ?? null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
error: null,
});
break;
case 'progress':
setState((prev) => ({
...prev,
active: true,
transferType: event.transferType ?? prev.transferType,
filename: event.filename ?? prev.filename,
transferred: event.transferred ?? prev.transferred,
total: event.total ?? prev.total,
fileIndex: event.fileIndex ?? prev.fileIndex,
fileCount: event.fileCount ?? prev.fileCount,
finalizing: !!((event as Record<string, unknown>).finalizing),
}));
break;
case 'complete':
setState((prev) => ({ ...prev, active: false }));
break;
case 'error':
setState((prev) => ({
...prev,
active: false,
error: event.error ?? 'Unknown error',
}));
break;
}
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
setState(initialState);
});
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
};
}, [sessionId]);
const cancel = useCallback(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
return { ...state, cancel };
}

View File

@@ -172,7 +172,7 @@ const attachSessionToTerminal = (
term: XTerm,
id: string,
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
onConnected?: () => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
@@ -209,6 +209,9 @@ const attachSessionToTerminal = (
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.updateStatus("disconnected");
if (evt.error) {
ctx.setError(evt.error);
}
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {

View File

@@ -7,7 +7,7 @@
"use strict";
const { execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const { existsSync, statSync } = require("node:fs");
const path = require("node:path");
// ── ANSI / URL regexes ──
@@ -93,7 +93,11 @@ function normalizeCliPathForPlatform(filePath) {
if (!normalized) return null;
if (process.platform !== "win32") {
return existsSync(normalized) ? normalized : null;
// Reject directories (e.g. /Applications/Codex.app) — must be a file
try {
if (existsSync(normalized) && statSync(normalized).isFile()) return normalized;
} catch { /* stat failed */ }
return null;
}
const ext = path.extname(normalized).toLowerCase();

View File

@@ -7,9 +7,11 @@
const https = require("node:https");
const http = require("node:http");
const path = require("node:path");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const fs = require("node:fs");
const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -60,7 +62,6 @@ const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
}
function cleanupAcpProvider(chatSessionId) {
// Clean up temporary COPILOT_HOME directory regardless of whether a
// provider entry exists — prepareCopilotHome may have succeeded before
// provider creation failed.
try {
const tempDirBridge = require("./tempDirBridge.cjs");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
if (existsSync(tempCopilotHome)) {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
}
} catch {
// Best-effort cleanup
}
const entry = acpProviders.get(chatSessionId);
if (!entry) return;
const rootPid = entry.provider?.model?.agentProcess?.pid;
cleanupAcpProviderInstance(entry.provider, chatSessionId);
acpProviders.delete(chatSessionId);
}
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
if (!provider) return;
const rootPid = provider?.model?.agentProcess?.pid;
const childPids = getChildProcessTreePids(rootPid);
try {
if (typeof entry.provider.forceCleanup === "function") {
entry.provider.forceCleanup();
} else if (typeof entry.provider.cleanup === "function") {
entry.provider.cleanup();
if (typeof provider.forceCleanup === "function") {
provider.forceCleanup();
} else if (typeof provider.cleanup === "function") {
provider.cleanup();
}
} catch (err) {
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
}
killTrackedProcessTree(rootPid, childPids);
acpProviders.delete(chatSessionId);
}
function isActiveAcpRun(chatSessionId, requestId) {
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
return Boolean(activeRun && activeRun.requestId === requestId);
}
function isUnsupportedLoadSessionError(err) {
function shouldRetryFreshSession(err) {
const message = String(err?.message || err || "").toLowerCase();
return message.includes("method not found") && message.includes("session/load");
return (message.includes("method not found") && message.includes("session/load"))
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
}
function getChildProcessTreePids(rootPid) {
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
}
}
function summarizeMcpServersForDebug(mcpServers) {
if (!Array.isArray(mcpServers)) return [];
return mcpServers.map((server) => ({
name: server?.name || "",
type: server?.type || "",
command: server?.command || "",
args: Array.isArray(server?.args) ? server.args : [],
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
url: server?.url || "",
}));
}
function logAcpDebug(agentLabel, message, details) {
const prefix = `[ACP DEBUG][${agentLabel}]`;
if (details === undefined) {
console.log(prefix, message);
return;
}
try {
console.log(prefix, message, JSON.stringify(details));
} catch {
console.log(prefix, message, details);
}
}
function normalizeAgentCommandName(command) {
if (typeof command !== "string" || !command) return "";
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
}
function matchesAgentCommand(command, expectedName) {
if (typeof command !== "string" || typeof expectedName !== "string") return false;
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
}
function envPairsToObject(entries) {
if (!Array.isArray(entries)) return {};
const result = {};
for (const entry of entries) {
if (!entry || typeof entry.name !== "string") continue;
result[entry.name] = entry.value == null ? "" : String(entry.value);
}
return result;
}
function mapMcpServerToCopilotConfig(server) {
if (!server || typeof server !== "object" || !server.name) return null;
if (server.type === "stdio" || server.type === "local") {
return {
type: "local",
command: server.command || "",
args: Array.isArray(server.args) ? server.args : [],
env: envPairsToObject(server.env),
tools: ["*"],
};
}
if (server.type === "http" || server.type === "sse") {
return {
type: server.type,
url: server.url || "",
headers: envPairsToObject(server.headers),
tools: ["*"],
};
}
return null;
}
function safeReadJson(filePath) {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
const tempDirBridge = require("./tempDirBridge.cjs");
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
try {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
} catch {
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
}
fs.mkdirSync(tempCopilotHome, { recursive: true });
if (realCopilotHome && existsSync(realCopilotHome)) {
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
}
const configPath = path.join(tempCopilotHome, "mcp-config.json");
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
const mergedServers = { ...(baseConfig.mcpServers || {}) };
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
const mapped = mapMcpServerToCopilotConfig(server);
if (!mapped) continue;
mergedServers[server.name] = mapped;
}
fs.writeFileSync(
configPath,
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
{ mode: 0o600 },
);
return {
copilotHome: tempCopilotHome,
configPath,
serverNames: Object.keys(mergedServers),
};
}
/**
* Make a streaming HTTP request and forward SSE events back to renderer
*/
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
{
command: "copilot",
name: "GitHub Copilot CLI",
icon: "copilot",
description: "GitHub's coding agent CLI",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
args: ["-p", "{prompt}"],
},
];
const shellEnv = await getShellEnv();
@@ -1303,12 +1453,16 @@ function registerHandlers(ipcMain) {
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
// --version failed: not a valid CLI executable (e.g. .app bundle)
continue;
}
if (!version) continue;
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
available: true,
@@ -1327,7 +1481,9 @@ function registerHandlers(ipcMain) {
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
resolvedPath = normalizeCliPathForPlatform(customPath);
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
@@ -1341,7 +1497,12 @@ function registerHandlers(ipcMain) {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
// --version failed: not a valid CLI executable
return { path: resolvedPath, version: null, available: false };
}
if (!version) {
return { path: resolvedPath, version: null, available: false };
}
return { path: resolvedPath, version, available: true };
@@ -1521,6 +1682,7 @@ function registerHandlers(ipcMain) {
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-agent-acp",
"codex", "codex-acp",
"copilot",
]);
// Spawn an external agent process
@@ -1730,6 +1892,102 @@ function registerHandlers(ipcMain) {
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let provider = null;
let copilotConfigInfo = null;
try {
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
const agentEnv = { ...shellEnv };
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
provider = createACPProvider({
command: resolvedCommand,
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
}
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
} catch {
// Ignore cleanup failures for transient model-discovery providers.
}
// Clean up transient COPILOT_HOME created for model listing
if (copilotConfigInfo?.copilotHome) {
try {
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
} catch { /* best-effort */ }
}
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
@@ -1771,8 +2029,10 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1811,6 +2071,13 @@ function registerHandlers(ipcMain) {
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
chatSessionId,
scopedIds,
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
});
}
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
@@ -1821,9 +2088,7 @@ function registerHandlers(ipcMain) {
const currentPermissionMode = mcpServerBridge.getPermissionMode();
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
!shouldForceProviderReset &&
providerEntry &&
providerEntry.acpCommand === acpCommand &&
providerEntry.cwd === sessionCwd &&
@@ -1840,6 +2105,11 @@ function registerHandlers(ipcMain) {
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
@@ -1850,6 +2120,7 @@ function registerHandlers(ipcMain) {
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
const provider = createACPProvider({
command: resolvedCommand,
@@ -1857,15 +2128,31 @@ function registerHandlers(ipcMain) {
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: sessionMcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Creating ACP provider", {
requestId,
chatSessionId,
cwd: sessionCwd,
resolvedCommand,
resolvedArgs,
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
});
}
providerEntry = {
provider,
acpCommand,
@@ -1877,15 +2164,21 @@ function registerHandlers(ipcMain) {
};
acpProviders.set(chatSessionId, providerEntry);
}
acpForceProviderReset.delete(chatSessionId);
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
throw err;
}
@@ -1901,13 +2194,22 @@ function registerHandlers(ipcMain) {
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
env: (() => {
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
}
return fallbackEnv;
})(),
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
@@ -1924,6 +2226,14 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
@@ -2042,6 +2352,9 @@ function registerHandlers(ipcMain) {
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
hasContent = true;
}
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
}
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: serialized,
@@ -2057,6 +2370,13 @@ function registerHandlers(ipcMain) {
// If stream completed with zero content, likely an auth or connection issue
if (!hasContent && !abortController.signal.aborted) {
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Stream completed with no content", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
});
}
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
@@ -2095,9 +2415,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
cleanupAcpProvider(chatSessionId);
}
acpChatRuns.delete(chatSessionId);
}
}
@@ -2127,10 +2444,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
acpForceProviderReset.add(effectiveChatSessionId);
cleanupAcpProvider(effectiveChatSessionId);
}
// Preserve the ACP provider session on stop so the next user message can
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
@@ -2143,7 +2456,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
acpForceProviderReset.delete(chatSessionId);
cleanupAcpProvider(chatSessionId);
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };

View File

@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
if (!socket.destroyed) socket.write(response);
return;
}
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -629,6 +630,22 @@ function handleExec(params) {
// ── MCP Server Config Builder ──
function resolveMcpServerRuntimeCommand() {
const runtimeCommand = process.execPath;
const runtimeEnv = [];
if (runtimeCommand && existsSync(runtimeCommand)) {
const basename = path.basename(runtimeCommand).toLowerCase();
const isNodeBinary = basename === "node" || basename.startsWith("node.");
if (!isNodeBinary) {
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
}
return { command: runtimeCommand, env: runtimeEnv };
}
return { command: "node", env: runtimeEnv };
}
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const runtime = resolveMcpServerRuntimeCommand();
const env = [
...runtime.env,
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: "node",
command: runtime.command,
args: [runtimePath],
env,
};

View File

@@ -24,6 +24,7 @@ const {
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
// Default SSH key names in priority order (preferred keys tried first)
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -410,9 +411,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
username: jump.username || 'root',
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// 0 = disabled (no keepalive packets sent)
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(options.legacyAlgorithms),
@@ -680,9 +681,9 @@ async function startSSHSession(event, options) {
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
readyTimeout: 20000, // Fast failure for non-interactive auth
// Use user-configured keepalive interval (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// 0 = disabled (no keepalive packets sent)
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(options.legacyAlgorithms),
@@ -1246,15 +1247,36 @@ async function startSSHSession(event, options) {
}
};
const sshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(buf);
trackSessionIdlePrompt(session, decoded);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
},
writeToRemote(buf) {
try { return stream.write(buf); } catch { return true; /* ignore */ }
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
getWebContents() {
return event.sender;
},
label: "SSH",
});
session.zmodemSentry = sshZmodemSentry;
stream.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(data);
trackSessionIdlePrompt(session, decoded);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
// In normal mode, sentry's onData callback handles decoding and buffering.
sshZmodemSentry.consume(data);
});
stream.stderr?.on("data", (data) => {
// stderr is not used for ZMODEM — decode normally
const decoder = getSessionDecoder(sessionId, "stderr");
const decoded = decoder.write(data);
bufferData(decoded);
@@ -1294,6 +1316,7 @@ async function startSSHSession(event, options) {
} else {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
}
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1362,6 +1385,7 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1382,6 +1406,7 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1412,6 +1437,7 @@ async function startSSHSession(event, options) {
}
}
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);

View File

@@ -14,6 +14,7 @@ const { SerialPort } = require("serialport");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
// Shared references
let sessions = null;
@@ -286,6 +287,7 @@ function startLocalSession(event, payload) {
rows: payload?.rows || 24,
env,
cwd,
encoding: null, // Return Buffer for ZMODEM binary support
});
const session = {
@@ -329,11 +331,40 @@ function startLocalSession(event, payload) {
});
session.flushPendingData = flushLocal;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
// On Windows, node-pty ignores encoding: null and still emits UTF-8
// strings, making raw-byte ZMODEM impossible for local PTY sessions.
// Only wire up the sentry on platforms where encoding: null works.
if (process.platform !== "win32") {
const localDecoder = new StringDecoder("utf8");
const zmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = localDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferLocalData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Local",
});
session.zmodemSentry = zmodemSentry;
proc.onData((data) => {
zmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
proc.onExit((evt) => {
flushLocal();
@@ -535,19 +566,57 @@ async function startTelnetSession(event, options) {
contents?.send("netcatty:data", { sessionId, data });
});
const telnetZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = telnetDecoder.write(buf);
if (!decoded) return;
const session = sessions.get(sessionId);
if (session) trackSessionIdlePrompt(session, decoded);
bufferTelnetData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
},
writeToRemote(buf) {
// Escape 0xFF bytes as 0xFF 0xFF per Telnet spec so binary
// ZMODEM data passes through without being treated as IAC.
try {
let hasFF = false;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0xff) { hasFF = true; break; }
}
if (hasFF) {
const escaped = [];
for (let i = 0; i < buf.length; i++) {
escaped.push(buf[i]);
if (buf[i] === 0xff) escaped.push(0xff);
}
return socket.write(Buffer.from(escaped));
} else {
return socket.write(buf);
}
} catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(telnetWebContentsId);
},
label: "Telnet",
});
// Attach sentry to session once created (connect callback runs after this)
const attachTelnetSentry = () => {
const session = sessions.get(sessionId);
if (session) session.zmodemSentry = telnetZmodemSentry;
};
socket.once('connect', attachTelnetSentry);
socket.on('data', (data) => {
const session = sessions.get(sessionId);
if (!session) return;
// Always run Telnet negotiation — even during ZMODEM, the Telnet
// layer still escapes 0xFF as IAC IAC and sends control sequences.
const cleanData = handleTelnetNegotiation(data);
if (cleanData.length > 0) {
const decoded = telnetDecoder.write(cleanData);
if (decoded) {
trackSessionIdlePrompt(session, decoded);
bufferTelnetData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
}
telnetZmodemSentry.consume(cleanData);
}
});
@@ -562,6 +631,7 @@ async function startTelnetSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.zmodemSentry?.cancel();
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
}
@@ -577,6 +647,7 @@ async function startTelnetSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.zmodemSentry?.cancel();
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
}
@@ -645,6 +716,7 @@ async function startMoshSession(event, options) {
rows,
env,
cwd: os.homedir(),
encoding: null, // Return Buffer for ZMODEM binary support
});
const session = {
@@ -682,11 +754,37 @@ async function startMoshSession(event, options) {
});
session.flushPendingData = flushMosh;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
if (process.platform !== "win32") {
const moshDecoder = new StringDecoder("utf8");
const moshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = moshDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferMoshData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Mosh",
});
session.zmodemSentry = moshZmodemSentry;
proc.onData((data) => {
moshZmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
proc.onExit((evt) => {
flushMosh();
@@ -790,17 +888,33 @@ async function startSerialSession(event, options) {
});
}
serialPort.on('data', (data) => {
const decoded = serialDecoder.write(data);
if (decoded) {
const serialZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = serialDecoder.write(buf);
if (!decoded) return;
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
},
writeToRemote(buf) {
try { return serialPort.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Serial",
});
session.zmodemSentry = serialZmodemSentry;
serialPort.on('data', (data) => {
// data is already Buffer from serialport — feed to sentry
serialZmodemSentry.consume(data);
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
session.zmodemSentry?.cancel();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
@@ -809,6 +923,7 @@ async function startSerialSession(event, options) {
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
session.zmodemSentry?.cancel();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
@@ -830,7 +945,15 @@ async function startSerialSession(event, options) {
function writeToSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
// During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer)
if (session.zmodemSentry?.isActive()) {
if (payload.data === '\x03') {
session.zmodemSentry.cancel();
}
return;
}
try {
if (session.stream) {
session.stream.write(payload.data);
@@ -887,6 +1010,7 @@ function closeSession(event, payload) {
if (!session) return;
try {
session.zmodemSentry?.cancel();
session.flushPendingData?.();
if (session.stream) {
session.stream.close();
@@ -999,6 +1123,7 @@ function cleanupAllSessions() {
console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`);
for (const [sessionId, session] of sessions) {
try {
session.zmodemSentry?.cancel();
if (session.stream) {
session.stream.close();
session.conn?.end();

View File

@@ -675,6 +675,11 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Clear reference when the main window is destroyed
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
@@ -917,6 +922,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
}
const win = new BrowserWindow({
title: "netcatty Settings",
width: settingsWidth,
height: settingsHeight,
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
@@ -1042,6 +1048,9 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
settingsWindow = null;
});
// Prevent HTML <title> from overriding the window title
win.on('page-title-updated', (e) => { e.preventDefault(); });
// Load the settings page
const settingsPath = '/#/settings';

View File

@@ -0,0 +1,794 @@
/**
* ZMODEM Helper - Provides ZMODEM file transfer support for terminal sessions.
*
* Architecture: ZMODEM detection and transfer runs entirely in the main process.
* The Sentry wraps the raw data stream and routes data either to the normal
* string-based terminal pipeline (via `to_terminal`) or to the ZMODEM protocol
* handler. This avoids any changes to the IPC / preload / renderer data path.
*
* The renderer is only notified for progress display via lightweight IPC events.
*/
const Zmodem = require("zmodem.js");
const fs = require("node:fs");
const path = require("node:path");
// Lazy-load electron to avoid issues when requiring from non-electron contexts
let _electron = null;
function getElectron() {
if (!_electron) _electron = require("electron");
return _electron;
}
/**
* Create a ZMODEM sentry that wraps a session's data stream.
*
* All raw data from the PTY / SSH stream / socket should be fed into
* `consume()`. The sentry transparently calls `onData(str)` for normal
* terminal output and handles ZMODEM transfers internally.
*
* @param {object} opts
* @param {string} opts.sessionId
* @param {(data: Buffer) => void} opts.onData
* Called with raw bytes during normal (non-ZMODEM) operation.
* The caller is responsible for charset-aware decoding (UTF-8, iconv, etc.).
* @param {(buf: Buffer) => void} opts.writeToRemote
* Write raw bytes back to the remote side (PTY / SSH stream / socket).
* @param {() => import('electron').WebContents | null} opts.getWebContents
* Returns the Electron WebContents for sending progress IPC events.
* @param {string} [opts.label]
* Human-readable label for log messages (e.g. "Local", "SSH").
* @returns {ZmodemSentryWrapper}
*/
function createZmodemSentry(opts) {
const {
sessionId,
onData,
writeToRemote,
getWebContents,
interruptRemote,
label = "Session",
} = opts;
let active = false;
let currentZSession = null;
let _needsDrain = false;
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let cancelInterruptTimer = null;
let ignoreDetectionUntil = 0;
// After aborting, suppress incoming data briefly so residual ZMODEM
// protocol bytes from the remote don't flood the terminal as garbage.
let cooldownUntil = 0;
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
function prunePendingEchoes(now = Date.now()) {
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
pendingEchoes.shift();
}
}
function rememberOutgoingEcho(octets) {
const buf = Buffer.from(octets);
if (!buf.length || buf.length > ECHO_MAX_BYTES) return;
prunePendingEchoes();
pendingEchoes.push({
buf,
expiresAt: Date.now() + ECHO_TTL_MS,
});
}
function stripEchoedOutgoingData(data) {
if (!pendingEchoes.length) return data;
prunePendingEchoes();
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let mutated = false;
while (pendingEchoes.length && buf.length) {
const nextEcho = pendingEchoes[0].buf;
if (buf.length < nextEcho.length) break;
if (!buf.subarray(0, nextEcho.length).equals(nextEcho)) break;
mutated = true;
buf = buf.subarray(nextEcho.length);
pendingEchoes.shift();
}
return mutated ? buf : data;
}
function stripPendingTerminalSuppression(data) {
if (!pendingTerminalSuppression?.length) return data;
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fullMatchAt = buf.indexOf(pendingTerminalSuppression);
if (fullMatchAt !== -1) {
buf = Buffer.concat([
buf.subarray(0, fullMatchAt),
buf.subarray(fullMatchAt + pendingTerminalSuppression.length),
]);
pendingTerminalSuppression = null;
return buf;
}
const maxMatch = Math.min(pendingTerminalSuppression.length, buf.length);
let matchLen = 0;
while (matchLen < maxMatch && buf[matchLen] === pendingTerminalSuppression[matchLen]) {
matchLen += 1;
}
if (!matchLen) return buf;
buf = buf.subarray(matchLen);
pendingTerminalSuppression = matchLen === pendingTerminalSuppression.length
? null
: pendingTerminalSuppression.subarray(matchLen);
return buf;
}
function stripVisibleZmodemHeaders(data) {
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let searchFrom = 0;
while (searchFrom < buf.length) {
const prefixAt = buf.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42]), searchFrom);
if (prefixAt === -1) break;
const minHeaderLength = 20;
if (buf.length - prefixAt < minHeaderLength) break;
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
isHexHeader = false;
break;
}
}
if (!isHexHeader) {
searchFrom = prefixAt + 1;
continue;
}
let headerLength = 18;
if (buf[prefixAt + 18] === 0x0d && buf[prefixAt + 19] === 0x0a) {
headerLength = 20;
if (buf[prefixAt + 20] === 0x11) {
headerLength = 21;
}
}
buf = Buffer.concat([
buf.subarray(0, prefixAt),
buf.subarray(prefixAt + headerLength),
]);
searchFrom = prefixAt;
}
return buf;
}
function looksLikeResidualZmodemData(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!buf.length) return true;
for (const byte of buf) {
const isResidualControl =
byte === 0x18 || // CAN / ZDLE
byte === 0x08 || // backspace from abort sequence
byte === 0x11 || // XON
byte === 0x13 || // XOFF
byte === 0x0d ||
byte === 0x0a;
if (isResidualControl) continue;
return false;
}
return true;
}
function sendExtraAbortBytes() {
try {
writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]));
} catch {
/* ignore */
}
}
function scheduleRemoteInterruptAfterCancel(transferRole) {
if (cancelInterruptTimer) {
clearTimeout(cancelInterruptTimer);
cancelInterruptTimer = null;
}
if (transferRole !== "send") return;
ignoreDetectionUntil = Date.now() + 300;
try { interruptRemote?.(); } catch { /* ignore */ }
// Some rz builds (notably Debian's lrzsz) can stay attached to the tty
// after a protocol cancel. Follow up with Ctrl+C so the remote shell
// reliably regains control. If rz is already gone, this just refreshes
// the prompt like a normal interactive interrupt.
cancelInterruptTimer = setTimeout(() => {
cancelInterruptTimer = null;
try { interruptRemote?.(); } catch { /* ignore */ }
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 120);
}
function isIgnorableSendKeepaliveError(errMsg) {
return Boolean(
active &&
currentZSession?.type === "send" &&
!currentZSession?._sending_file &&
errMsg.includes("Unhandled header: ZRINIT")
);
}
function isIgnorableSendResumePingError(errMsg) {
return Boolean(
active &&
currentZSession?.type === "send" &&
!currentZSession?._sending_file &&
currentZSession?._next_header_handler?.ZRINIT &&
errMsg.includes("Unhandled header: ZRPOS")
);
}
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data pass raw bytes to the caller for charset-aware decoding.
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
sender(octets) {
// ZMODEM protocol bytes send raw to remote.
rememberOutgoingEcho(octets);
const ok = writeToRemote(Buffer.from(octets));
// Track backpressure: if stream.write() returned false, the
// kernel TCP buffer is full. The upload loop should pause.
if (ok === false) _needsDrain = true;
},
on_detect(detection) {
if (active) {
console.warn(`[ZMODEM][${label}] Detection while transfer active; denying`);
detection.deny();
return;
}
if (Date.now() < ignoreDetectionUntil) {
console.log(`[ZMODEM][${label}] Ignoring stray detection during cancel grace window`);
detection.deny();
return;
}
active = true;
const zsession = detection.confirm();
currentZSession = zsession;
pendingTerminalSuppression = zsession.type === "receive"
? Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex())
: zsession._last_ZRINIT?.to_hex
? Buffer.from(zsession._last_ZRINIT.to_hex())
: null;
const contents = getWebContents();
const transferType = zsession.type === "send" ? "upload" : "download";
console.log(`[ZMODEM][${label}] Detected ${transferType} for session ${sessionId}`);
safeSend(contents, "netcatty:zmodem:detect", {
sessionId,
transferType,
});
// Provide a drain helper so the upload loop can pause when the
// underlying transport's write buffer is full.
const transferOpts = {
...opts,
waitForDrain: () => {
if (!_needsDrain) return Promise.resolve();
_needsDrain = false;
// Yield to the event loop so Node can flush buffered writes to
// the kernel. Using setImmediate (not setTimeout) avoids any
// fixed delay — we resume as soon as the I/O phase completes.
return new Promise((resolve) => setImmediate(resolve));
},
};
handleTransfer(zsession, transferType, transferOpts)
.then(() => {
// Only act if this is still the active session (not replaced by a new one)
if (currentZSession !== zsession) return;
console.log(`[ZMODEM][${label}] Transfer completed for session ${sessionId}`);
safeSend(contents, "netcatty:zmodem:complete", { sessionId });
})
.catch((err) => {
if (currentZSession !== zsession) return;
console.error(`[ZMODEM][${label}] Transfer error:`, err.message || err);
try { zsession.abort(); } catch { /* ignore */ }
safeSend(contents, "netcatty:zmodem:error", {
sessionId,
error: String(err.message || err),
});
})
.finally(() => {
// Only clear state if this is still the active session
if (currentZSession === zsession) {
active = false;
currentZSession = null;
}
});
},
on_retract() {
// False positive sentry automatically resumes passthrough.
},
});
return {
/**
* Feed raw bytes from the session into the sentry.
* @param {Buffer|Uint8Array} data
*/
consume(data) {
// During cooldown after abort, unconditionally suppress all incoming
// data. sz can stream large amounts of file data that's still in
// SSH/TCP buffers after we send CAN; checking content doesn't help
// because the residual data contains arbitrary printable bytes.
if (cooldownUntil) {
const now = Date.now();
if (now < cooldownUntil) {
// Keep sending CAN in case earlier ones were lost in the flood
if (now - (cooldownUntil - COOLDOWN_MS) > 200) {
sendExtraAbortBytes();
}
return; // drop everything during cooldown
}
cooldownUntil = 0;
// After cooldown, let this chunk through — it's likely the shell prompt
}
try {
const sanitizedData = stripEchoedOutgoingData(data);
if (!sanitizedData.length) return;
sentry.consume(sanitizedData);
} catch (err) {
const errMsg = String(err.message || err);
console.error(`[ZMODEM][${label}] Sentry consume error:`, errMsg);
const wasActive = active;
// lrzsz's `rz` may resend ZRINIT while we're waiting for the user
// to choose files. zmodem.js doesn't model that pre-offer keepalive,
// but the repeated header is harmless, so ignore it and keep waiting.
if (isIgnorableSendKeepaliveError(errMsg)) {
console.log(`[ZMODEM][${label}] Ignoring repeated pre-offer ZRINIT`);
return;
}
// Some receivers emit a final ZRPOS ping right before they send the
// post-file ZRINIT. If that ping is processed a beat late, zmodem.js
// complains even though the transfer can continue normally.
if (isIgnorableSendResumePingError(errMsg)) {
console.log(`[ZMODEM][${label}] Ignoring late post-file ZRPOS`);
return;
}
// ZFIN/OO mismatch: the file transfer completed (ZFIN exchanged)
// but the shell prompt arrived before the "OO" end marker. This
// is common over SSH because sz exits and the shell resumes before
// the "OO" acknowledgement is sent. Treat as successful transfer.
// Do NOT abort() here — that sends CAN bytes to the remote shell.
// Instead, manually clean up the sentry's internal session state.
if (wasActive && errMsg.includes("ZFIN") && errMsg.includes("OO")) {
console.log(`[ZMODEM][${label}] ZFIN/OO mismatch — treating as success`);
if (currentZSession) {
try { currentZSession._on_session_end(); } catch { /* ignore */ }
}
active = false;
currentZSession = null;
safeSend(getWebContents(), "netcatty:zmodem:complete", { sessionId });
try { sentry.consume(data); } catch { /* ignore */ }
return;
}
// For all other errors, abort and send extra CAN sequences to
// ensure the remote rz/sz process stops transmitting.
if (currentZSession) {
try { currentZSession.abort(); } catch { /* ignore */ }
}
sendExtraAbortBytes();
// Follow up with Ctrl+C after a short delay to kill rz/sz on
// Debian and other systems where it stays attached after CAN.
setTimeout(() => {
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 150);
active = false;
currentZSession = null;
// Enter cooldown: discard incoming data briefly while the remote
// processes our CAN sequence and stops sending ZMODEM frames.
cooldownUntil = Date.now() + COOLDOWN_MS;
if (wasActive) {
safeSend(getWebContents(), "netcatty:zmodem:error", {
sessionId,
error: errMsg,
});
}
}
},
/** Whether a ZMODEM transfer is currently in progress. */
isActive() {
return active;
},
/** Cancel the current ZMODEM transfer. */
cancel() {
if (currentZSession) {
const transferRole = currentZSession.type;
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
try { currentZSession.abort(); } catch { /* ignore */ }
sendExtraAbortBytes();
active = false;
currentZSession = null;
cooldownUntil = Date.now() + COOLDOWN_MS;
scheduleRemoteInterruptAfterCancel(transferRole);
safeSend(getWebContents(), "netcatty:zmodem:error", {
sessionId,
error: "Transfer cancelled",
});
}
},
};
}
// ---------------------------------------------------------------------------
// Shared helpers (module-level, usable from handleUpload / handleDownload)
// ---------------------------------------------------------------------------
/**
* Race a promise against a timeout. If the promise doesn't settle within
* `ms`, resolve with undefined instead of hanging forever. This prevents
* zmodem.js internal promises (xfer.end, zsession.close) from blocking
* indefinitely after cancel/abort.
*/
function withTimeout(promise, ms) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("ZMODEM handshake timeout")), ms);
}),
]).finally(() => clearTimeout(timer));
}
/**
* Send CAN bytes + delayed Ctrl-C to kill the remote rz/sz process.
* Used from dialog-cancel paths that run outside the sentry closure.
*/
function abortRemoteProcess(writeToRemote) {
try { writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18])); } catch { /* ignore */ }
setTimeout(() => {
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 150);
}
// ---------------------------------------------------------------------------
// Transfer handlers
// ---------------------------------------------------------------------------
async function handleTransfer(zsession, transferType, opts) {
if (transferType === "upload") {
await handleUpload(zsession, opts);
} else {
await handleDownload(zsession, opts);
}
}
/**
* Upload files to the remote (remote executed `rz`).
*/
async function handleUpload(zsession, opts) {
const { sessionId, getWebContents } = opts;
const contents = getWebContents();
const { BrowserWindow, dialog } = getElectron();
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
const result = await dialog.showOpenDialog(win || undefined, {
properties: ["openFile", "multiSelections"],
title: "Select files to upload (ZMODEM)",
});
if (result.canceled || !result.filePaths.length) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
throw new Error("Transfer cancelled");
}
const filePaths = result.filePaths;
const fileStats = filePaths.map((fp) => fs.statSync(fp));
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const stat = fileStats[i];
const name = path.basename(filePath);
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
const xfer = await zsession.send_offer({
name,
size: stat.size,
mtime: new Date(stat.mtimeMs),
files_remaining: filePaths.length - i,
bytes_remaining: bytesRemaining,
});
if (!xfer) {
// Receiver skipped this file
continue;
}
// Read and send in chunks
const CHUNK_SIZE = 64 * 1024; // Leave room for inbound ZMODEM control frames
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(CHUNK_SIZE);
let sent = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buf, 0, CHUNK_SIZE);
if (bytesRead === 0) break;
// zmodem.js send() is synchronous and triggers writeToRemote via
// the sentry's sender callback. Yield after each chunk so the
// event loop can flush buffered writes and process inbound control
// frames, preventing unbounded memory growth on slow links.
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, bytesRead));
sent += bytesRead;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
});
// Wait for transport to drain if its buffer is full, then yield
// so inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) await opts.waitForDrain();
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
// flushing to the remote. Show "finalizing" state while we
// wait for the remote to acknowledge.
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: stat.size,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
finalizing: true,
});
await withTimeout(xfer.end(), 120000);
} finally {
fs.closeSync(fd);
}
}
await withTimeout(zsession.close(), 120000);
}
/**
* Download files from the remote (remote executed `sz <file>`).
*/
async function handleDownload(zsession, opts) {
const { sessionId, getWebContents } = opts;
const contents = getWebContents();
const { BrowserWindow, dialog } = getElectron();
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
let fileIndex = 0;
const pendingStreams = [];
const pendingOffers = [];
let lastProgressTime = 0;
let downloadDir = null;
let rejectSession = () => {};
const processOffer = (xfer, reject) => {
if (!downloadDir) {
pendingOffers.push(xfer);
return;
}
const detail = xfer.get_details();
// Sanitize filename to prevent path traversal attacks
const rawName = detail.name || `untitled_${Date.now()}`;
const name = path.basename(rawName);
const size = detail.size || 0;
const savePath = path.join(downloadDir, name);
const currentIndex = fileIndex++;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: size,
fileIndex: currentIndex,
fileCount: -1, // unknown total until session ends
transferType: "download",
});
// Avoid overwriting existing files — append (1), (2), etc.
let finalPath = savePath;
if (fs.existsSync(savePath)) {
const ext = path.extname(name);
const base = path.basename(name, ext);
let n = 1;
do {
finalPath = path.join(downloadDir, `${base} (${n})${ext}`);
n++;
} while (fs.existsSync(finalPath));
}
const ws = fs.createWriteStream(finalPath);
let received = 0;
let writeAborted = false;
// Track pending write streams (and paths) for cleanup at session end
pendingStreams.push({ stream: ws, path: finalPath, completed: false });
ws.on("error", (err) => {
writeAborted = true;
console.error(`[ZMODEM] Write stream error for ${name}:`, err.message);
ws.destroy();
reject(err);
});
xfer.accept({
on_input(payload) {
if (writeAborted) return;
const chunk = Buffer.from(payload);
ws.write(chunk);
received += chunk.length;
// Throttle progress IPC to ~10 updates/sec to avoid
// overwhelming the renderer on fast links.
const now = Date.now();
if (now - lastProgressTime >= 100) {
lastProgressTime = now;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: received,
total: size,
fileIndex: currentIndex,
fileCount: -1,
transferType: "download",
});
}
},
}).catch((err) => {
ws.destroy();
reject(err);
});
xfer.on("complete", () => {
const entry = pendingStreams.find((e) => e.stream === ws);
if (entry) entry.completed = true;
ws.end();
});
};
const sessionPromise = new Promise((resolve, reject) => {
rejectSession = reject;
zsession.on("offer", (xfer) => {
try {
processOffer(xfer, reject);
} catch (err) {
reject(err);
}
});
// Wait for all write streams to finish flushing before resolving.
// If a stream never received end() (e.g. transfer was cancelled),
// destroy it so the fd is released and finish/close can fire.
zsession.on("session_end", async () => {
try {
await Promise.all(
pendingStreams.map((entry) => {
const { stream: s, path: filePath, completed } = entry;
if (s.writableFinished) {
// Delete partial files that never completed
if (!completed) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
return Promise.resolve();
}
if (!s.writableEnded) s.destroy();
return new Promise((r) => {
s.on("close", () => {
// Clean up partial downloads
if (!completed) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
r();
});
});
})
);
} catch { /* ignore — error handler already called reject */ }
resolve();
});
});
// Start the session BEFORE showing the dialog so lrzsz doesn't
// time out waiting for ZRINIT while the user browses for a folder.
zsession.start();
const result = await dialog.showOpenDialog(win || undefined, {
properties: ["openDirectory", "createDirectory"],
title: "Select download directory (ZMODEM)",
});
if (result.canceled || !result.filePaths.length) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
void sessionPromise.catch(() => {});
throw new Error("Transfer cancelled");
}
downloadDir = result.filePaths[0];
while (pendingOffers.length) {
processOffer(pendingOffers.shift(), rejectSession);
}
await sessionPromise;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function safeSend(contents, channel, data) {
try {
if (contents && !contents.isDestroyed()) {
contents.send(channel, data);
}
} catch {
// WebContents may have been destroyed between the check and the send
}
}
module.exports = { createZmodemSentry };

View File

@@ -318,8 +318,8 @@ function registerAppProtocol() {
function focusMainWindow() {
try {
const wins = BrowserWindow.getAllWindows();
const win = wins && wins.length ? wins[0] : null;
const mainWin = getWindowManager().getMainWindow?.();
const win = mainWin && !mainWin.isDestroyed?.() ? mainWin : null;
if (!win) return false;
// Check if the webContents has crashed or been destroyed
@@ -505,6 +505,14 @@ const registerBridges = (win) => {
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
// ZMODEM cancel handler
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (session?.zmodemSentry) {
session.zmodemSentry.cancel();
}
});
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
ipcMain.handle("netcatty:figspec:list", async () => {
try {
@@ -1066,12 +1074,11 @@ if (!gotLock) {
} catch {}
if (focusMainWindow()) return;
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
});
}
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
});
});
});

View File

@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
const transferErrorListeners = new Map();
const transferCancelledListeners = new Map();
const chainProgressListeners = new Map();
const zmodemListeners = new Map();
const sftpConnectionProgressListeners = new Set();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
@@ -109,6 +110,28 @@ function _deliverToListeners(sessionId, data) {
});
}
// ZMODEM file transfer events
ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "progress", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:complete", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "complete", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
@@ -153,6 +176,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
}
dataListeners.delete(payload.sessionId);
exitListeners.delete(payload.sessionId);
zmodemListeners.delete(payload.sessionId);
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
@@ -569,6 +593,14 @@ const api = {
},
setSessionEncoding: (sessionId, encoding) =>
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
onZmodemEvent: (sessionId, cb) => {
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
zmodemListeners.get(sessionId).add(cb);
return () => zmodemListeners.get(sessionId)?.delete(cb);
},
cancelZmodem: (sessionId) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
},
onSessionData: (sessionId, cb) => {
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
dataListeners.get(sessionId).add(cb);
@@ -1207,6 +1239,9 @@ const api = {
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
},
aiAcpCancel: async (requestId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
},

17
global.d.ts vendored
View File

@@ -263,6 +263,23 @@ declare global {
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
// ZMODEM file transfer
onZmodemEvent?(
sessionId: string,
cb: (event: {
type: 'detect' | 'progress' | 'complete' | 'error';
sessionId: string;
transferType?: 'upload' | 'download';
filename?: string;
transferred?: number;
total?: number;
fileIndex?: number;
fileCount?: number;
finalizing?: boolean;
error?: string;
}) => void
): () => void;
cancelZmodem?(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,

View File

@@ -337,7 +337,7 @@ body {
/* Dim terminal text in unfocused workspace panes (default) */
.workspace-pane:not(:focus-within) .xterm-screen {
opacity: 0.65;
opacity: 0.82;
}
/* Border-style focus indicator (opt-in via data attribute) */
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {

View File

@@ -0,0 +1,69 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
};
function getCommandBasename(command: string | undefined): string {
const normalized = String(command || '').trim();
if (!normalized) return '';
const parts = normalized.split(/[\\/]/);
return (parts.pop() || '').toLowerCase();
}
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || '').trim();
return normalized.includes('/') || normalized.includes('\\');
}
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
const basename = getCommandBasename(command);
return basename === agentKey || basename.startsWith(`${agentKey}.`);
}
export function isSettingsManagedDiscoveredAgent(
agent: Pick<DiscoveredAgent, 'command'>,
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
const basename = getCommandBasename(agent.command);
return (
agent.id === `discovered_${agentKey}` ||
agent.acpCommand === meta.acpCommand ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managedId = `discovered_${agentKey}`;
const preferredAgent = agents.find(
(agent) =>
agent.id === managedId &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
if (preferredAgent) {
return preferredAgent.command;
}
const fallbackAgent = agents.find(
(agent) =>
matchesManagedAgentConfig(agent, agentKey) &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
return fallbackAgent?.command ?? null;
}

View File

@@ -1677,5 +1677,329 @@ export const TERMINAL_THEMES: TerminalTheme[] = [
brightCyan: '#83c092',
brightWhite: '#5c6d64'
}
}
},
{
id: 'github-dark',
name: 'GitHub Dark',
type: 'dark',
colors: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#2f81f7',
selection: '#264f78',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#ffffff',
}
},
{
id: 'github-light',
name: 'GitHub Light',
type: 'light',
colors: {
background: '#ffffff',
foreground: '#1f2328',
cursor: '#0969da',
selection: '#add6ff',
black: '#24292f',
red: '#cf222e',
green: '#116329',
yellow: '#4d2d00',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#6e7781',
brightBlack: '#57606a',
brightRed: '#a40e26',
brightGreen: '#1a7f37',
brightYellow: '#633c01',
brightBlue: '#218bff',
brightMagenta: '#a475f9',
brightCyan: '#3192aa',
brightWhite: '#8c959f',
}
},
{
id: 'ubuntu',
name: 'Ubuntu',
type: 'dark',
colors: {
background: '#300a24',
foreground: '#eeeeec',
cursor: '#bbbbbb',
selection: '#b5d5ff',
black: '#2e3436',
red: '#cc0000',
green: '#4e9a06',
yellow: '#c4a000',
blue: '#3465a4',
magenta: '#75507b',
cyan: '#06989a',
white: '#d3d7cf',
brightBlack: '#555753',
brightRed: '#ef2929',
brightGreen: '#8ae234',
brightYellow: '#fce94f',
brightBlue: '#729fcf',
brightMagenta: '#ad7fa8',
brightCyan: '#34e2e2',
brightWhite: '#eeeeec',
}
},
{
id: 'one-dark-pro',
name: 'One Dark Pro',
type: 'dark',
colors: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
selection: '#3e4452',
black: '#3f4451',
red: '#e05561',
green: '#8cc265',
yellow: '#d18f52',
blue: '#4aa5f0',
magenta: '#c162de',
cyan: '#42b3c2',
white: '#d7dae0',
brightBlack: '#4f5666',
brightRed: '#ff616e',
brightGreen: '#a5e075',
brightYellow: '#f0a45d',
brightBlue: '#4dc4ff',
brightMagenta: '#de73ff',
brightCyan: '#4cd1e0',
brightWhite: '#e6e6e6',
}
},
{
id: 'horizon-dark',
name: 'Horizon',
type: 'dark',
colors: {
background: '#1c1e26',
foreground: '#d5d8da',
cursor: '#6c6f93',
selection: '#6c6f93',
black: '#16161c',
red: '#e95678',
green: '#29d398',
yellow: '#fab795',
blue: '#26bbd9',
magenta: '#ee64ac',
cyan: '#59e1e3',
white: '#d5d8da',
brightBlack: '#6c6f93',
brightRed: '#ec6a88',
brightGreen: '#3fdaa4',
brightYellow: '#fbc3a7',
brightBlue: '#3fc4de',
brightMagenta: '#f075b5',
brightCyan: '#6be4e6',
brightWhite: '#ffffff',
}
},
{
id: 'palenight',
name: 'Palenight',
type: 'dark',
colors: {
background: '#292d3e',
foreground: '#bfc7d5',
cursor: '#ffcc00',
selection: '#7580b8',
black: '#292d3e',
red: '#ff5572',
green: '#a9c77d',
yellow: '#ffcb6b',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#89ddff',
white: '#d0d0d0',
brightBlack: '#676e95',
brightRed: '#ff5572',
brightGreen: '#c3e88d',
brightYellow: '#ffcb6b',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#89ddff',
brightWhite: '#ffffff',
}
},
{
id: 'panda',
name: 'Panda',
type: 'dark',
colors: {
background: '#292a2b',
foreground: '#e6e6e6',
cursor: '#ff4b82',
selection: '#454647',
black: '#757575',
red: '#ff2c6d',
green: '#19f9d8',
yellow: '#ffb86c',
blue: '#45a9f9',
magenta: '#ff75b5',
cyan: '#b084eb',
white: '#cdcdcd',
brightBlack: '#757575',
brightRed: '#ff2c6d',
brightGreen: '#19f9d8',
brightYellow: '#ffcc95',
brightBlue: '#6fc1ff',
brightMagenta: '#ff9ac1',
brightCyan: '#bcaafe',
brightWhite: '#e6e6e6',
}
},
{
id: 'snazzy',
name: 'Snazzy',
type: 'dark',
colors: {
background: '#1e1f29',
foreground: '#ebece6',
cursor: '#e4e4e4',
selection: '#81aec6',
black: '#000000',
red: '#fc4346',
green: '#50fb7c',
yellow: '#f0fb8c',
blue: '#49baff',
magenta: '#fc4cb4',
cyan: '#8be9fe',
white: '#ededec',
brightBlack: '#555555',
brightRed: '#fc4346',
brightGreen: '#50fb7c',
brightYellow: '#f0fb8c',
brightBlue: '#49baff',
brightMagenta: '#fc4cb4',
brightCyan: '#8be9fe',
brightWhite: '#ededec',
}
},
{
id: 'synthwave-84',
name: "Synthwave '84",
type: 'dark',
colors: {
background: '#262335',
foreground: '#f0eff1',
cursor: '#72f1b8',
selection: '#463465',
black: '#241b30',
red: '#fe4450',
green: '#72f1b8',
yellow: '#fede5d',
blue: '#03edf9',
magenta: '#ff7edb',
cyan: '#03edf9',
white: '#f0eff1',
brightBlack: '#7f7094',
brightRed: '#fe4450',
brightGreen: '#72f1b8',
brightYellow: '#f9f972',
brightBlue: '#aa54f9',
brightMagenta: '#ff7edb',
brightCyan: '#03edf9',
brightWhite: '#f2f2e3',
}
},
{
id: 'vesper',
name: 'Vesper',
type: 'dark',
colors: {
background: '#101010',
foreground: '#ffffff',
cursor: '#acb1ab',
selection: '#988049',
black: '#101010',
red: '#f5a191',
green: '#90b99f',
yellow: '#e6b99d',
blue: '#aca1cf',
magenta: '#e29eca',
cyan: '#ea83a5',
white: '#a0a0a0',
brightBlack: '#7e7e7e',
brightRed: '#ff8080',
brightGreen: '#99ffe4',
brightYellow: '#ffc799',
brightBlue: '#b9aeda',
brightMagenta: '#ecaad6',
brightCyan: '#f591b2',
brightWhite: '#ffffff',
}
},
{
id: 'kanso-dark',
name: 'Kanso Dark',
type: 'dark',
colors: {
background: '#090e13',
foreground: '#c5c9c7',
cursor: '#c5c9c7',
selection: '#393b44',
black: '#0d0c0c',
red: '#c4746e',
green: '#8a9a7b',
yellow: '#c4b28a',
blue: '#8ba4b0',
magenta: '#a292a3',
cyan: '#8ea4a2',
white: '#c8c093',
brightBlack: '#a4a7a4',
brightRed: '#e46876',
brightGreen: '#87a987',
brightYellow: '#e6c384',
brightBlue: '#7fbbb3',
brightMagenta: '#938aa9',
brightCyan: '#7aa89f',
brightWhite: '#c5c9c7',
}
},
{
id: 'kanso-light',
name: 'Kanso Light',
type: 'light',
colors: {
background: '#f2f1ef',
foreground: '#22262d',
cursor: '#22262d',
selection: '#e2e1df',
black: '#22262d',
red: '#c84053',
green: '#6f894e',
yellow: '#77713f',
blue: '#4d699b',
magenta: '#b35b79',
cyan: '#597b75',
white: '#545464',
brightBlack: '#6d6f6e',
brightRed: '#d7474b',
brightGreen: '#6e915f',
brightYellow: '#836f4a',
brightBlue: '#6693bf',
brightMagenta: '#624c83',
brightCyan: '#5e857a',
brightWhite: '#43436c',
}
},
];

22
package-lock.json generated
View File

@@ -57,6 +57,7 @@
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -8282,6 +8283,18 @@
"buffer": "^5.1.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -16276,6 +16289,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zmodem.js": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
"license": "Apache-2.0",
"dependencies": {
"crc-32": "^1.1.1"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@@ -75,6 +75,7 @@
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB