Compare commits

...

99 Commits

Author SHA1 Message Date
陈大猫
646e7ce001 fix(tray): update Windows tray click and right-click behaviors to match conventions (#1152) (#1268)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-06 11:08:14 +08:00
陈大猫
21da34187e Merge pull request #1267 from binaricat/codex/fix-cmd-pty-wrapper 2026-06-06 02:59:35 +08:00
LAPTOP-O016UC3M\Qi Chen
d2fa4f1cd9 Fix cmd PTY wrapper expansion 2026-06-06 02:28:16 +08:00
陈大猫
72a6fc14f9 Merge pull request #1263 from binaricat/codex/domestic-ai-provider-presets
Add domestic AI provider presets
2026-06-05 16:52:04 +08:00
bincxz
97c2cb1f86 Keep OpenRouter model discovery covered 2026-06-05 16:49:00 +08:00
bincxz
73fd091b80 Cover provider dropdown presentation 2026-06-05 16:43:52 +08:00
bincxz
5bb4052f3d Keep preset model suggestions visible 2026-06-05 16:39:39 +08:00
bincxz
36e7e3cb7f Keep add provider menu inside settings panel 2026-06-05 16:37:54 +08:00
bincxz
25b73187f5 Update domestic provider model suggestions 2026-06-05 16:35:29 +08:00
bincxz
75a9600089 Document Xiaomi provider icon source 2026-06-05 16:30:32 +08:00
bincxz
c9216b32ab Add domestic AI provider presets 2026-06-05 16:28:21 +08:00
陈大猫
70e374ef11 Merge pull request #1262 from binaricat/codex/proxy-type-dropdown
Use dropdown for proxy type selection
2026-06-05 16:02:25 +08:00
bincxz
24840c539c fix(proxy): use dropdown for proxy type 2026-06-05 15:59:21 +08:00
陈大猫
461be76821 Merge pull request #1260 from binaricat/codex/proxy-command-1257
feat(ssh): support ProxyCommand connections
2026-06-05 15:49:38 +08:00
bincxz
28a7184cc4 fix(proxy): harden ProxyCommand summaries 2026-06-05 15:35:21 +08:00
陈奇
13f1453276 test(proxy): avoid ProxyCommand stdin race 2026-06-05 06:42:49 +00:00
陈奇
ff25c36ede test(proxy): harden ProxyCommand stream coverage 2026-06-05 06:34:12 +00:00
陈奇
092aa45fd9 Fix ProxyCommand shell substitution 2026-06-05 06:11:31 +00:00
陈大猫
64acf80024 Merge pull request #1259 from binaricat/codex/chacha20-poly1305-1256
fix(ssh): support chacha20-poly1305 cipher
2026-06-05 14:08:52 +08:00
陈奇
099beb8438 feat(ssh): support proxy command connections 2026-06-05 06:03:45 +00:00
陈奇
8bee13c3f9 fix(ssh): offer chacha20-poly1305 cipher 2026-06-05 05:54:23 +00:00
陈大猫
65cd8aba79 Merge pull request #1258 from binaricat/feat/sftp-skip-loading-on-reuse
feat(sftp): skip loading animation when reusing terminal SSH connection
2026-06-05 12:07:08 +08:00
陈奇
37856e5608 fix: set reusedConnection on initial connect, tolerate bridge fallback
When sourceSessionId is requested but the bridge falls back to a
fresh connection, the pane remains non-interactive (loading=true)
with stale cached files shown — acceptable trade-off vs always
showing the distracting spinner for near-instant reused connections.
2026-06-05 04:05:04 +00:00
陈奇
5b1deaa08a fix: clear reusedConnection flag after SFTP connection established
The flag was persisting forever, suppressing loading UI for all
subsequent navigations and refreshes. Clear it once status
becomes 'connected' so only the initial reuse skips the spinner.
2026-06-05 03:58:54 +00:00
陈奇
a41bced1d7 feat(sftp): skip loading animation when reusing terminal SSH connection
When SFTP reuses an existing terminal SSH connection, the
connection is near-instant so the loading spinner and overlay
are distracting noise. Added a reusedConnection flag to
SftpConnection and skip the loading UI when set.

Changes:
- SftpConnection model: +reusedConnection boolean
- useSftpConnections: set reusedConnection when sourceSessionId exists
- SftpPaneToolbar: skip animate-spin for reused connections
- SftpPaneFileList: skip loading overlay for reused connections
- SftpPaneTreeView: skip loading overlay for reused connections

Follow-up to #1254
2026-06-05 03:56:13 +00:00
陈大猫
8c207a1dff Merge pull request #1254 from binaricat/feat/sftp-reuse-terminal-connection
feat(sftp): reuse terminal SSH connection for side panel SFTP
2026-06-05 11:07:47 +08:00
陈奇
99e1974a69 fix: add activeSessionId to auto-connect effect deps
When activeSessionId arrives after activeHost (e.g. focus
update in workspace), the effect must re-run to pass the
session ID to connect() — otherwise SFTP falls back to a
fresh SSH connection.
2026-06-05 03:04:30 +00:00
陈奇
132bf288ac feat(sftp): reuse terminal SSH connection for side panel SFTP
When opening the SFTP side panel for a host that already has an
active terminal session, reuse the terminal's authenticated SSH
connection instead of creating a new one.

Changes:
- TerminalLayer: compute activeTerminalSessionIdForSftp, matching
  hostname/port/username against the active session
- TerminalLayerView: pass activeSessionId to SftpSidePanel
- SftpSidePanel: accept activeSessionId, pass to connect()
- useSftpConnections: pass sourceSessionId to bridge.openSftp()
- sftpBridge/openConnection: try to find and reuse terminal session's
  SSH connection via findReusableSession, fall back to fresh connection
- sftpBridge: wire up acquireConnectionRef/releaseConnectionRef for
  shared connection lifecycle

Only SSH (non-mosh/et/local) connected sessions are reused. Falls
back gracefully to a fresh connection on any reuse failure.
2026-06-05 03:01:23 +00:00
陈大猫
0a9f9848b7 Merge pull request #1252 from binaricat/fix/telnet-default-port-1251
fix(host-details): auto-switch default port when toggling primary protocol between SSH/Telnet
2026-06-05 10:22:13 +08:00
陈奇
11da55abf7 fix: respect group telnet port defaults during protocol switch
When a host is in a group with telnetPort configured, switching
protocol should not override the port to 23 — let the group default
take effect. Also handles undefined port during switch (fallback to
protocol default when no group defaults exist).
2026-06-05 02:15:50 +00:00
陈奇
e751c0f23e fix: preserve group-inherited Telnet port on save
When a host inherits its Telnet port from a group config
(groupDefaults.telnetPort), the save handler should leave
port as undefined rather than materialising 23.

Added hasGroupTelnetPortDefault parameter to
resolvePrimaryProtocolSavePort to preserve inheritance.
2026-06-05 02:12:30 +00:00
陈奇
f4b5beec01 fix(host-details): auto-switch default port when toggling primary protocol between SSH/Telnet
When a user toggles the primary protocol to Telnet, the port field
previously stayed at 22 (SSH default). Now it auto-switches:
- SSH → Telnet: port 22 → 23
- Telnet → SSH: port 23 → 22
Custom ports are preserved.

Also fixes the save handler to fall back to 23 for telnet when
no explicit port is set, matching the telnet protocol default.

Closes #1251
2026-06-05 02:09:09 +00:00
陈大猫
9e2b8093fb fix terminal split connection reuse (#1249)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-04 23:44:27 +08:00
陈大猫
7b2f66000c Support pasting local files into SFTP (#1248)
* feat(sftp): paste local clipboard files

* fix(sftp): handle native clipboard paste event

* fix(sftp): harden clipboard paste upload
2026-06-04 23:08:32 +08:00
陈大猫
6e7593dee2 improve codex live progress (#1247) 2026-06-04 22:14:10 +08:00
陈大猫
2098b2b09d fix copilot thinking stream (#1243) 2026-06-04 21:57:30 +08:00
陈大猫
8181fe71cf fix(ai): make SDK deps optional, degrade gracefully when missing (#1242)
* fix(ai): make SDK deps optional, degrade gracefully when missing

- Move @anthropic-ai/claude-agent-sdk and @github/copilot-sdk
  from dependencies to optionalDependencies so npm install does
  not fail when they are unavailable
- claudeDriver.listClaudeModels: catch import error, return [] silently
- copilotDriver.listCopilotModels: catch import error, return [] silently
- sdkStreamHandlers: downgrade log from console.error to console.debug

The renderer already falls back to curated model presets when
list-models returns [], so no functional change.

* fixup: honor queryFn before SDK import; regenerate lockfile with optional markers

* fixup: guard runTurn against missing SDK modules

runClaudeTurn and runCopilotTurn now catch dynamic import errors
and emit a user-friendly error message instead of crashing with
a raw module-not-found error.
2026-06-04 19:53:29 +08:00
陈大猫
d06009684e fix(ai): redact attachment payloads during context compaction (#1241) 2026-06-04 19:40:04 +08:00
陈大猫
55236ce34a fix(terminal): use composed CJK font stack (#1233) 2026-06-04 19:29:17 +08:00
陈大猫
b89f06b7f0 fix(terminal): prevent horizontal content drift (#1240) 2026-06-04 19:28:58 +08:00
陈大猫
a01d1f770f feat(sftp): add Open with system default for native Windows file association (fixes #1236) (#1239)
* feat(sftp): add Open with system default for native Windows file association (fixes #1236)

Adds a new 'Open with system default' option to the SFTP context menu
that uses Electron's shell.openPath() to invoke the native OS file opening
mechanism. On Windows, this uses ShellExecute, which works with UWP/WinUI
apps (Photos, Paint, etc.) whose executable paths change with updates.

The existing 'Open with...' (browse for executable) is preserved.

Closes #1236

* fix(sftp): add file watch support to openWithSystemDefault for SFTP auto-sync

P2: The new default-app open flow was not starting a file watch
when SFTP auto-sync was enabled, so edits saved in the system
default app were not uploaded back to the remote host.

Mirrors the downloadToTempAndOpen behavior — accepts an optional
{ enableWatch } parameter and starts a file watch when set.

* fix(sftp): mark transfer as failed when system default open fails

P2: When shell.openPath fails for a remote file, the transfer queue
still shows success because downloadToTemp had already completed the
temp download. Now updates externalTransferId to 'failed' before
throwing, matching downloadToTempAndOpen's behavior.
2026-06-04 19:25:29 +08:00
陈大猫
f1fdb61195 Merge pull request #1212 from lateautumn233/feat/et
Add full EternalTerminal (ET) protocol support alongside the existing SSH, Mosh, Telnet, and Serial protocols. ET automatically reconnects when the network drops or the user's IP changes, making it ideal for mobile and unreliable networks.
2026-06-04 18:55:18 +08:00
bincxz
9d0f6a9cea Merge origin/main into feat/et 2026-06-04 18:12:54 +08:00
bincxz
e0403412e7 fix(et): harden session auth and dev binary fetch 2026-06-04 18:09:53 +08:00
陈大猫
bb67aa77f5 [codex] compact long Catty agent context (#1237)
* feat: compact long Catty agent context

* fix: tighten context compaction review issues

* fix: preserve tool context during compaction

* fix: clear stale model metadata on key edits
2026-06-04 17:56:39 +08:00
atoz03
e948a7a869 fix: route Cmd+W through existing tab close flow (#1234)
* fix: route Cmd+W through existing tab close flow

Keep the original tab-close behavior intact, and close the main or settings window only when there is no active closable tab to handle.

* fix: fall back to closing non-listener windows on Cmd+W

Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
2026-06-04 17:20:44 +08:00
陈大猫
3fc56df111 refactor(ai): migrate agent backends from ACP to official SDKs (claude/codex/copilot) (#1229) 2026-06-04 17:11:02 +08:00
陈大猫
008890a688 feat(terminal): 自定义本地 Shell 支持启动参数 (#1221) (#1225)
* feat(terminal): 支持为自定义本地 Shell 配置启动参数 (#1221)

自定义本地 Shell 此前只能填可执行文件路径,无法指定启动参数,
导致接入 msys2 bash 时缺少 `--login -i`,shell 不经 profile 初始化、
环境变量缺失而无法使用。

- TerminalSettings 新增 localShellArgs(string[],默认 []),随设置自动迁移
- 新增 domain/shellArgs:引号感知的命令行分词/回显格式化
- resolveShellSetting 透传自定义参数;参数为空时回退到 bridge 默认参数,
  命中已发现 shell(WSL/Git Bash)时忽略自定义参数,避免串味
- 自定义 Shell 弹窗新增「启动参数」输入,设置行展示完整命令(en/zh-CN/ru)

参数复用已有管线 session.localShellArgs → startLocalSession → pty.spawn,
不涉及云同步(与机器相关的 localShell 一致,均不同步)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): 修复自定义 Shell 参数的两处 review 问题

- 切换到默认/已发现 shell 时清空 localShellArgs:自定义参数仅对自定义
  路径有意义,清空可避免在发现列表尚未加载(启动竞态)或所选 shell ID
  在本机不可用时,旧参数被当作该 shell 的启动参数而泄漏导致启动失败
- formatShellArgs 对含双引号的参数改用单引号包裹,修复 `-c "..."`
  这类参数重新打开弹窗保存时被破坏的往返问题

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): shellArgs 量化处理含两种引号的参数往返

采用单引号优先的引号策略:单引号内全字面(Windows 路径、双引号原样保留,
无需转义);仅当 token 自身含单引号时改用双引号包裹并转义 \ 与 "。
解析端仅在双引号区内将 \" / \\ 视为转义,其余反斜杠保持字面,
确保未加引号的 Windows 路径不被破坏。修复 `echo "it's ok"` 这类同时含
单双引号的参数在弹窗重新保存时被破坏的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): 自定义 Shell 已选中后可重新打开编辑参数

自定义 shell 选中时下拉框值已是 __custom__,再次点选「自定义…」不会触发
onValueChange,导致无法重新打开弹窗修改参数/路径。改为把自定义 shell 概要
做成可点击的编辑入口(铅笔图标),点击即重新填充草稿并打开弹窗;
打开逻辑抽到 openCustomShellModal 复用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): shellArgs 改用 POSIX 单引号方案,修复尾部反斜杠与空参数

- 解析:两种引号区内均全字面(双引号不再把 \" 当转义),保留 Windows
  路径尾部反斜杠等手输入;引号外仅 \' 转义为字面单引号(支撑 '\'' 习语),
  其余反斜杠保持字面,未加引号的 Windows 路径不受影响
- 格式化:统一单引号包裹(内容全字面),内嵌单引号用 POSIX '\'' 习语;
  显式空参数输出为 '' 以免重新保存时被丢弃

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): customArgs take precedence over discovered shell defaults when user explicitly sets them

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:07:13 +08:00
陈大猫
178f56455e fix(terminal): destructure sshDebugLogEnabled in TerminalPane (#1231)
TerminalPane forwards sshDebugLogEnabled to its child at render (L717) but
never destructured it from props, so rendering threw
"ReferenceError: sshDebugLogEnabled is not defined". The prop already exists
in TerminalPaneProps and the memo comparator; only the destructuring was
missing. tsc flags it (TS2304) but the build does not gate on tsc. Introduced
in 85f486e6.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:54:13 +08:00
陈大猫
8376e35022 fix: macOS 自动更新装不上(进程退不掉) (#1224)
* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215)

macOS in-place auto-update downloaded and unpacked the new version but
never installed it: the app appeared to close, ShipIt never ran, and no
restart happened. A full uninstall + reinstall did not help; only a
manual DMG replace worked.

Root cause is a code-level coordination bug, not the release pipeline.
The published mac zips are correctly Developer-ID signed, notarized, and
stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and
latest-mac.yml is well-formed — so Squirrel.Mac signature validation
passes. The failure is that quitAndInstall() drives app.quit() while two
normal-quit behaviors keep the process alive:

  1. the main-window close handler hides to tray when close-to-tray is
     enabled (it only closes when isQuitting is true), and
  2. the before-quit dirty-editor guard preventDefault()s the quit for a
     5s renderer round-trip.

Either keeps the parent process running, so Squirrel.Mac's ShipIt helper
— which waits on the parent PID to die before swapping the bundle —
lands in launchd "pending spawn / on-demand-only" limbo and the service
is removed without installing. This matches the reporter's diagnosis
exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only).

Fix: before quitAndInstall fires app.quit(), mark the app as quitting
for an update via windowManager.setQuittingForUpdate(true). That sets
isQuitting (bypassing close-to-tray) and the before-quit handler now
returns early when isQuittingForUpdate() is true (skipping the
dirty-editor round-trip), so the process exits cleanly and ShipIt can
run. The same fix also covers the latent Windows NSIS case where
close-to-tray would block an in-place update.

If the install never actually quits the app (quitAndInstall throws, or
returns without app.quit() on a Squirrel follow-up error / stale
download), the quitting-for-update flags are rolled back — synchronously
on throw, and via a short unref'd watchdog otherwise — so the app does
not get stuck permanently bypassing close-to-tray and the quit guard.

Tests: unit tests for the new windowManager flags and for the install
handler — ordering (setQuittingForUpdate before quitAndInstall), tray
cleanup still runs, no-op when the updater fails to load, rollback on
synchronous throw, and watchdog rollback when the app never quits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): keep dirty-editor guard during update install

setQuittingForUpdate only bypasses close-to-tray (so the window actually
closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the
unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would
silently lose edits. If the user cancels to save, the quit aborts and
autoUpdateBridge's watchdog clears the quitting-for-update flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): clear update-quit state when the quit is cancelled

When the user clicks "Restart Now" with a dirty editor open, the before-quit
guard cancels the quit (settle "stay"). The update path had already called
setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray
for the install), so without clearing it the app stays in a quitting state —
close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s
watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): check for unsaved editors before quitAndInstall (#1215)

The previous fix bypassed close-to-tray so the app process actually exits and
Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit
dirty-editor guard as the unsaved-work safety net. But on macOS that net has a
hole: quitAndInstall() closes the window FIRST and only then fires before-quit.
Once setQuittingForUpdate(true) lets the main window truly close (instead of
hiding to tray), the before-quit guard can run after the window is already gone
— isReachableByUser is false, so it commits the quit and silently drops unsaved
SFTP edits.

Fix: move the dirty-editor check to the moment the user clicks "Restart Now",
in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the
window and renderer are still alive:

  - dirty   -> abort the install (don't set the quitting flags, don't
               quitAndInstall) and broadcast netcatty:update:needs-save so the
               renderer prompts the user to save and retry.
  - clean   -> proceed with the existing flow (commit-to-quit, tray cleanup,
               quitAndInstall, watchdog).
  - no reachable main window / crashed renderer -> install directly (no user to
               ask), matching the before-quit fail-open path.

The before-quit dirty guard is kept as defense-in-depth: if the window is still
reachable it re-checks (clean, since we just verified), and if it's already gone
it lets the quit through — which is now safe because the install handler already
confirmed there were no unsaved editors.

The request/reply/timeout round-trip is extracted into a shared helper,
electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install
handler and main.cjs's before-quit guard use one implementation. main.cjs's
before-quit is refactored onto it (behavior preserved: sender-filtered reply,
fail-open timeout, and the setQuittingForUpdate(false) rollback when the user
cancels to save).

The needs-save notice is BROADCAST to every window, not just the queried main
window: "Restart to Update" can be clicked from the Settings window, which would
otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the
subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx
(main window) and SettingsPage (settings window) — pass an onNeedsSave callback
that shows an actionable toast ("save your editors, then click Restart Now
again") in en / zh-CN / ru.

Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On
macOS quitAndInstall() can return while Squirrel is still pulling the downloaded
ZIP from the local update server before it closes the windows; on a large/slow
update that can exceed 10s. Clearing isQuitting that early would let the eventual
native quit hit a non-quitting close-to-tray handler and strand the install
again — the exact #1215 failure. The longer window only fires when the app is
realistically stuck, at the cost of close-to-tray staying bypassed a little
longer in the rare genuine-failure case.

Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or
crashed webContents / send-throws / no-ipcMain paths); install handler pre-check
(dirty -> no quitAndInstall + needs-save broadcast to all windows; clean ->
quitAndInstall runs; no main window -> installs without asking). Existing
install-handler tests updated for the now-async handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:05:09 +08:00
陈大猫
0b8206aecb fix(terminal): encode input with the session charset (#1216) (#1222)
The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).

Make input symmetric with output:

- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
  charset normalization plus encodeTerminalInput(), which encodes a keystroke
  string with the same iconv charset (returning the string untouched for UTF-8
  and for unset/unknown encodings so the transport's native serialization and
  the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
  sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
  encodeTerminalInput(session.encoding) before writing to the SSH stream,
  telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
  encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
  output decoder everywhere the decoder is configured:
    * Telnet/serial already stored session.encoding; writeToSession now reads
      it for input too.
    * SSH mirrors session.encoding wherever it sets sessionEncodings: the
      GB-variant pre-seed at session start and the runtime setEncoding handler.
      The pre-seed stays gated to GB variants to match the renderer's two-value
      encoding state, so behavior for other/arbitrary charsets is unchanged —
      the renderer still pushes the effective encoding via setEncoding on
      attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
  input.

Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.

Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:08:21 +08:00
陈大猫
203505bc25 fix: persist GoogleDrive tokens after silent refresh (#1219)
GoogleDriveAdapter refreshed its access token only in memory: the rotated
tokens were never written back, so the next launch loaded a stale access
token and the user was forced to reconnect. This is the same defect #1208
fixed for OneDrive, which GoogleDriveAdapter never received because it did
not expose setOnTokensRefreshed — making the shared
attachTokenRefreshPersistence a no-op for Google.

Add setOnTokensRefreshed to GoogleDriveAdapter and route both refresh
points (setTokens / ensureValidToken) through a refreshTokens helper that
stores the rotated tokens in memory and notifies the persistence callback,
so attachTokenRefreshPersistence now encrypts and persists them.

Google's refresh response usually omits refresh_token (it does not rotate
on every refresh), so refreshTokens carries the previous refresh token
forward when the response lacks one — otherwise the persisted connection
would become unrefreshable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:23:59 +08:00
陈大猫
c5ac85ae5b fix: OneDrive 同步 access token 自动续期与失效重连提示 (#1189) (#1208)
* Auto-refresh OneDrive sync token and prompt reconnect on dead refresh token

OneDrive cloud sync (#1189) broke for users on 1.1.20 and only recovered
after disconnecting and re-authorizing. Two gaps caused this:

1. Refreshed tokens were never persisted. When OneDriveAdapter silently
   refreshed the access token mid-session, the rotated tokens lived only in
   the adapter's in-memory state — CloudSyncManager never read them back or
   saved them. Microsoft consumer refresh tokens rotate on every refresh and
   invalidate the previous one, so the next app launch loaded a stale,
   rotated-out refresh token. Eventually that stored token was dead and sync
   failed, forcing a manual reconnect.

   Fix: OneDriveAdapter now exposes setOnTokensRefreshed(); the manager wires
   it so every silent refresh writes the rotated tokens back into provider
   state and encrypted storage (attachTokenRefreshPersistence /
   persistRefreshedProviderTokens), keeping the stored refresh token current.

2. A genuinely dead refresh token surfaced as a raw, generic error with no
   guidance. The bridge now detects invalid_grant / interaction_required /
   consent_required / login_required on refresh and tags the error with a
   stable marker. OneDriveAdapter normalizes these to
   OneDriveReauthRequiredError; the marker survives IPC and error re-wrapping
   so the condition stays detectable, and the UI strips it to show a clean
   "OneDrive session expired, please reconnect." message.

Shared marker + detection/clean helpers live in domain/sync.ts so the bridge,
adapter, and UI use one source of truth. Scope is limited to OneDrive; other
providers (Gist/iCloud/Google/WebDAV/S3) are untouched.

Tests: OneDriveAdapter refresh-persistence + reauth detection, manager
token-persistence wiring, and bridge invalid_grant tagging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Drop OneDrive to a reconnect state when its refresh token is dead

codex review: detecting a dead refresh token was not enough. A provider in
`error` state that still holds tokens stays "ready for sync"
(isProviderReadyForSync), and syncAllProviders resets such providers back to
`connected` and retries — so auto-sync kept hammering the dead refresh token
and the user never got a stable reconnect prompt.

Now, when a sync/download error indicates OneDrive reauth is required, the
manager clears the stale tokens and tears down the cached adapter
(handleProviderReauthRequired), leaving the provider in an error state with no
credentials. With no tokens, isProviderReadyForSync returns false so auto-sync
stops retrying, and the card shows a clean "please reconnect" message with a
Connect button. The account is preserved for display; the error message is
stripped of the internal marker.

Wired into the syncToProvider / uploadToProvider / downloadFromProvider error
paths. Added tests for the clear-on-reauth behavior and provider/error scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Clear OneDrive reauth during inspection paths; include service tests in npm test

codex review round 2:

P2 — A dead OneDrive refresh token can also surface during startup remote
inspection and the syncAllProviders preflight conflict check, both of which run
through inspectProviderRemoteState. That path swallowed the error into an
{error} tuple without clearing the stale tokens, so the provider stayed
retryable. Wired the reauth handler into inspectProviderRemoteState's catch,
covering sync preflight, syncAll preflight, and startup inspection in one place.
Made the handler idempotent so the operation's own catch can also call it
without re-saving.

P3 — New tests live under infrastructure/services/, which npm test's globs did
not cover (the pre-existing syncAllStorageMethods.test.ts was also uncovered).
Added infrastructure/services/*.test.ts and infrastructure/services/*/*.test.ts
to the test script.

Full suite: 1400 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: drop unmatched services test glob from npm test

The npm test script listed both infrastructure/services/*.test.ts and the
nested infrastructure/services/*/*.test.ts. No .test.ts files live directly
under infrastructure/services/ (the OneDrive adapter and cloudSync tests are
in adapters/ and cloudSync/ subdirs), so the flat glob never matched.

Under a default POSIX shell (sh/bash without nullglob), an unmatched glob is
passed through literally, so node --test received the raw pattern. On Node
versions without test-runner glob support this aborts with
"Could not find '.../infrastructure/services/*.test.ts'" before any test runs,
breaking npm test.

Remove the redundant flat glob; the nested glob already covers the new
OneDrive adapter and cloudSync tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:26:33 +08:00
陈大猫
c6552ddc75 fix: Mosh 模式显示主机信息(stats companion SSH 连接) (#1198) (#1213)
* Show host info for Mosh sessions via a stats companion SSH connection

Mosh sessions run over UDP through a local mosh-client PTY and carry no
ssh2 connection (session.conn), so getServerStats could not open an exec
channel and the terminal's host-info bar (CPU/memory/disk/network) stayed
empty — unlike SSH sessions (issue #1198).

Add a best-effort, non-interactive companion SSH connection that is opened
lazily on the first stats poll for a Mosh session, reusing the credentials
the Mosh handshake already validated, and assign it to session.conn so the
existing stats path works unchanged:

- electron/bridges/sshBridge/moshStatsConnection.cjs: new helper that
  builds the companion connection. It never prompts (only stored password,
  parseable private key, unencrypted/stored-passphrase identity files, or
  ssh-agent), shares one in-flight attempt across concurrent polls, treats
  auth rejection as permanent but transient errors as retryable, and skips
  host-key verification like the existing one-off execCommand path.
- sessionOps.getServerStats: establish the companion connection for Mosh
  sessions that lack session.conn before running the stats command;
  degrades gracefully to the existing "not connected" error otherwise.
- moshSession.swapToMoshClient: stash the handshake credentials and
  algorithm settings on session.moshStatsAuth once the handshake succeeds.
- terminalBridge closeSession / cleanupAllSessions and the mosh-client exit
  handler: tear down the companion connection (it has no session.stream).
- Forward legacyAlgorithms / skipEcdsaHostKey / algorithmOverrides through
  the renderer's Mosh starter and the bridge type so the companion
  negotiates the same algorithms the interactive session would.

Tests cover the helper (auth selection, agent fallback, dedup, permanent vs
transient failure, late-ready discard), the getServerStats integration, and
the moshStatsAuth stash + companion teardown on close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: target SSH host and settle on mid-handshake close

- moshSession: store options.hostname (the SSH endpoint) on moshStatsAuth
  instead of parsed.host. A `MOSH IP` line advertises the UDP endpoint for
  mosh-client, which can differ from the SSH host on NAT / multi-homed
  setups; the companion is an SSH connection and must target the SSH host.
- moshStatsConnection: resolve the pending attempt from the "close" handler
  when the socket drops mid-handshake without a prior "ready"/"error", so an
  awaiting getServerStats call (and session.moshStatsConnPromise) cannot
  hang indefinitely. Treated as transient so the next poll may retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: keyboard-interactive password for stats companion

PAM-backed SSH servers often offer password auth only via
keyboard-interactive, not the plain "password" method. The Mosh handshake's
system ssh handles that through its PTY responder, so without it the
companion stats connection would fail auth on those hosts even with a saved
password, leaving the stats bar empty.

When a saved password is present, enable tryKeyboard and attach a
non-interactive keyboard-interactive handler that auto-fills the password
for a single password prompt (using the existing
isAutoFillablePasswordChallenge predicate) and finishes empty on
2FA/OTP/multi-prompt challenges. It auto-fills at most once so a wrong
password can't drive a retry loop, and it never shows a modal — the
companion stays fully non-interactive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: verify host key before sending a saved password

A background companion connection that auto-submits a saved password to an
unverified host could disclose it to a spoofed / MITM server (P1). Gate
password auth (plain and keyboard-interactive) behind a silent, trusted-only
host-key check against Netcatty's known-hosts store:

- moshStatsConnection: when the companion would authenticate with a
  password, attach an ssh2 hostVerifier that accepts only a key already
  "trusted" in known-hosts (via hostKeyVerifier.classifyHostKey) and rejects
  unknown/changed keys outright — no prompt, so the password is never sent to
  an unvetted host and no host-key dialog pops for a background poll.
  Public-key / agent auth proves possession via a signature and discloses no
  reusable secret, so it is not gated (matches the existing execCommand
  precedent; the handshake already vetted the host via system ssh).
- Thread knownHosts through the renderer Mosh starter, the bridge type, and
  session.moshStatsAuth.

Note: a password-auth Mosh host that Netcatty has never seen via its own SSH
path (so it is absent from Netcatty's known-hosts) will not get the stats
companion until its key is known — the safe default. Key/agent-auth hosts are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: transient pre-handshake polls and agent+password auth

Two functional gaps in the stats companion:

- Missing moshStatsAuth is now transient, not permanent. The renderer can
  mark a Mosh session "connected" (and start polling) from the SSH
  bootstrap's visible PTY output before the swap to mosh-client assigns
  moshStatsAuth. Previously that first poll set moshStatsConnFailed
  permanently, so the companion was never attempted after the handshake
  actually completed. Now it just returns null and a later poll retries.

- A saved password no longer suppresses ssh-agent auth. A public-key host
  that authenticates via the agent may still carry a stored password; the
  companion now offers the agent alongside the password (ssh2 tries agent
  first) instead of attempting password-only and failing permanently. An
  explicit private key still suppresses the agent fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: gate password at authHandler, not whole connection

The previous fix installed a trusted-only hostVerifier whenever a password
was present. Because ssh2 verifies the host before any auth method, that
rejected the entire connection on a host absent from Netcatty's known-hosts
— blocking key/agent auth too, even though those never need to send the
password.

Move the gate from the transport to the auth layer:

- A trust-tracking hostVerifier records whether the live host key is trusted
  (during the transport handshake) and then accepts the transport so
  public-key / agent auth can proceed on any host.
- A function-form authHandler offers none -> agent -> publickey always, and
  appends password + keyboard-interactive only when the host key is trusted.

Result: key/agent auth works on hosts Netcatty hasn't vetted, while a saved
password is still never sent to an untrusted host. Public-key / agent auth
remains ungated (no reusable secret is disclosed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: isolate Mosh stats connection from session.conn

Storing the stats companion on session.conn made it look like the session's
primary interactive SSH connection. Other bridges key off session.conn —
getSessionPwd assumes its exec channel is a sibling of the interactive shell,
and SFTP / MCP exec run over session.conn — but a Mosh session's shell lives
on the UDP mosh-client, not this background connection. After a stats poll
they could return a bogus cwd or operate over the wrong connection.

Keep the companion strictly on session.moshStatsConn:

- ensureMoshStatsConnection stores/reuses/clears only session.moshStatsConn.
- getServerStats reads session.conn || session.moshStatsConn (real SSH still
  uses conn; Mosh uses the companion) and only opens one when neither exists.
- closeSession / cleanupAllSessions / the mosh-client exit handler tear down
  session.moshStatsConn.

This leaves session.conn untouched for Mosh, so getSessionPwd / SFTP / MCP
exec behave exactly as before (no primary SSH connection for Mosh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: don't count pre-handshake Mosh polls as failures

useServerStats gives up after 3 consecutive failures. A Mosh session can be
marked "connected" (and start polling) from the SSH bootstrap's visible
output before swapToMoshClient stores moshStatsAuth, during which
ensureMoshStatsConnection returns null. Previously getServerStats reported
that as a normal failure, so a handshake taking ~15s (3 polls) would
permanently disable stats for the session even after credentials became
available.

Introduce a `pending` result:

- getServerStats returns { success: false, pending: true } for a Mosh session
  that has no connection yet and no moshStatsAuth and hasn't permanently
  failed. Once moshStatsAuth is set (or the companion permanently fails), it
  reports a normal failure again.
- useServerStats treats `pending` as neutral: it does not update stats and
  does not increment the consecutive-failure counter, so polling continues
  until the handshake completes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: verify host key for all Mosh stats companion auth

The companion installed a trust-tracking host verifier only when a saved
password was present; key/agent-only connections fell back to ssh2's
default of accepting any host key. A background, user-invisible connection
that authenticated against an unverified host could let a MITM/DNS-spoofed
host feed bogus host-info to the user and enumerate the ssh-agent's public
keys — breaking the host-key guarantee the interactive session enforces.

Attach the host verifier for every auth method and reject an unknown or
changed host key outright (never prompting; stats just stay empty). Treat
an untrusted host as a permanent failure so polling stops reconnecting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mosh): trust system known_hosts for the stats companion host-key check

The Mosh stats companion opens a background ssh2 connection and only rides
on a host whose live key is already trusted, rejecting unknown/changed keys
as a permanent failure. Trust was sourced solely from Netcatty's in-app
known-hosts snapshot (options.knownHosts).

But a Mosh session is bootstrapped by the system `ssh`, which vets and
records the host key in the user's OpenSSH known_hosts (~/.ssh/known_hosts,
etc). Netcatty's snapshot is never updated by that handshake, so a host
trusted purely via system ssh was misread as "unknown" and the companion
permanently disabled — Mosh stats never appeared unless the user manually
scanned/imported the host into Netcatty (codex P2).

Add a system-known_hosts trust source (systemKnownHosts.cjs) and consult it
in the companion verifier when the in-app snapshot does not already vouch
for the key. Matching is by the LIVE key's SHA-256 fingerprint, so trust is
granted only for the exact key the user's own OpenSSH already trusts; an
arbitrary or mismatched key is never accepted. Unknown/changed keys stay
rejected and remain a permanent failure.

The parser handles the OpenSSH known_hosts(5) format that the in-app scan
parser does not fully cover for matching: plain hosts, comma lists,
[host]:port, hashed |1|salt|HMAC-SHA1(salt,token) entries (with the
bracketed token for non-default ports, verified against ssh-keygen -H),
multiple key types, @revoked (forces NOT trusted) and @cert-authority
(skipped). Wildcard/negation patterns are deliberately not honored. Paths
mirror localFsBridge.readKnownHosts and are cross-platform (incl. Windows
%PROGRAMDATA%\ssh\known_hosts). All errors fail closed.

ssh2 ships no known_hosts parser, so this is implemented in CommonJS using
node:crypto and covered by unit tests (real ssh-keygen hashed fixtures,
revoked/cert-authority, fingerprint mismatch, fail-closed) plus companion
integration tests (system-only trust accepts; neither source trusts ->
rejected + permanent; key rotation not rescued; optional-dependency safety).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:26:27 +08:00
bincxz
7972b19bdd fix(et): forward jump-host reference key path and custom ET port
From the local codex review of the ET jump-host changes:

- A jump host authenticated by a saved reference key had its on-disk key path
  dropped: privateKey is undefined for reference keys and only
  jumpHost.identityFilePaths was forwarded, so ET jump auth fell back to
  defaults despite a valid key being selected. Mirror startSSH and forward the
  reference key's filePath as an IdentityFile (with the same password-method
  and keyId fallback guards).
- A jump host listening on a non-default ET server port was always contacted
  on 2022: the bridge reads jump.etPort (--jport) but the renderer never sent
  it. Forward jumpHost.etPort and add etPort to NetcattyJumpHost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:25:08 +08:00
bincxz
33918a2433 fix(et): show the ET server port in the connection dialog
Copilot review: for an ET host the connection dialog displayed host.port
(the SSH port, e.g. 22), but ET connectivity hinges on the etserver port
(host.etPort, default 2022). Showing 22 is misleading when a connection is
actually stuck on the ET port. Display etPort (falling back to 2022) for ET
hosts instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:37 +08:00
bincxz
9e7f6d98fd fix(et): fail loudly on missing/over-long jump chain in startEt
Copilot review: startEt only checked resolvedChainHosts.length, so a
configured jump chain that failed to resolve (missing/invalid host ID) would
silently fall back to a direct connection — possibly to the wrong target —
unlike startSSH, which explicitly rejects missing chain host IDs.

Add the same getMissingChainHostIds check startSSH uses, erroring early with
a clear message when a configured jump host cannot be resolved. Also base the
"at most one jump host" limit on the configured chain (host.hostChain.hostIds)
rather than only the resolved list, so a second hop whose ID fails to resolve
cannot slip past the check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:32 +08:00
bincxz
ceda20510f fix(et): run Unix askpass via Electron exec and route jumps through ET --jumphost
Addresses two correctness issues from the codex review of the ET protocol
support.

codex P1 #1 (Unix askpass helper): on macOS/Linux the SSH_ASKPASS helper
was the askpass .cjs itself, relying on its `#!/usr/bin/env node` shebang.
Packaged Electron builds put no `node` on the user's PATH, so ssh could not
run the helper and ET could not supply the saved password / key passphrase,
failing the connection outright. Mirror the existing Windows .cmd wrapper:
write a small /bin/sh wrapper that execs the helper through process.execPath
with ELECTRON_RUN_AS_NODE=1 (process.execPath is POSIX single-quoted to
survive spaces in an .app path).

codex P1 #2 (jump host routing): a jumped ET host only got an ssh
ProxyCommand, which fixes the SSH bootstrap but leaves ET opening its TCP
socket straight at the destination etserver — which fails whenever the
destination ET port is not directly reachable. Use ET's own
--jumphost/--jport so ET connects its socket to the jumphost's etserver and
reaches the destination over the SSH tunnel ET sets up (`ssh -J`). Per-hop
jump credentials move from the ProxyCommand into a `Host <jumphost>` ssh_config
block (OpenSSH applies command-line -o only to the final hop, so jump settings
must come from config); the destination's comma/space options are scoped under
a `Host <dest>` block with a ProxyJump so the standalone ssh used for distro
detection tunnels through the jump too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:26 +08:00
lateautumn233
82f3250b5b Bundle ET client binaries in package build workflow
Mirror the existing mosh bundling pipeline so that release and tag
builds automatically fetch and package the EternalTerminal `et`
client alongside mosh-client.
2026-06-03 12:35:50 +08:00
陈大猫
c37e087332 Merge pull request #1209 from binaricat/feat/duplicate-tab-reuse-session-1204 2026-06-03 11:26:31 +08:00
bincxz
f282c58edc fix(ssh): validate reuse target and handle synchronous shell failures
Two more review findings on the connection-reuse path:

- Verify the source connection's endpoint matches the duplicate's
  requested hostname/port/username before reusing it. A saved host edited
  after the source tab connected would otherwise let the copy silently
  open on the old connection and run commands on the wrong machine.
  findReusableSession now takes the requested target and requires an exact
  endpoint match; the session records its actual SSH endpoint at connect.

- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
  synchronously (e.g. "Not connected") if the borrowed transport dropped
  between findReusableSession and the shell request; without this the
  up-front connection ref hold would leak. On a synchronous throw we now
  drop the error listener, release the ref, and fall back to a fresh
  connection.

Adds tests for endpoint mismatch and synchronous shell failure.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:07:58 +08:00
bincxz
45e208f1d8 fix(ssh): harden connection-reuse lifecycle against teardown races
Two reference-counting races found in review:

- Reuse path read the source's connRef only inside the async conn.shell()
  callback. If the source tab closed while the shell was opening,
  releaseConnectionRef could drop the count to zero and end the shared
  connection out from under the opening channel. Now pin the connection
  (acquireConnectionRef) before issuing the async shell request and hand
  the hold over to the real session once the channel opens; release it on
  any failure so fallback to a fresh connection doesn't leak the count.

- closeSession read session.connRef *after* stream.close(), but closing the
  channel can synchronously fire the stream "close" handler that nulls
  connRef and releases the connection — so the post-close check fell into
  the legacy path and ended the shared connection a second time. Snapshot
  the multiplexing flag before closing the channel.

Adds a regression test covering "source closed while the copied shell is
still opening".

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:59:49 +08:00
bincxz
132c597d1e fix(ssh): scope session-log stop to the owning connection
Reading _logStreamToken back off the session map in the connection-level
close/error/timeout handlers could let a late close from an old transport
stop a newer same-sessionId log stream after a reconnect, regressing the
token guard from #916. Capture the owner channel's log stream token in the
connection closure and pass it to stopStream, matching the original
closure-scoped behavior.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:51:09 +08:00
bincxz
85f486e6cd feat(ssh): reuse authenticated connection for Copy Tab to skip re-MFA
Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.

- copySession records the source session id (reuseConnectionFromSessionId)
  for connected, non-mosh SSH sessions; it is threaded down to the SSH
  bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
  source connection. The shell wiring is extracted into a shared
  setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
  module (mirrors Tabby ref/unref/destroy): the shared transport + jump
  host chain are torn down only when the last channel closes, so closing
  a copy — or the original while a copy is open — never kills siblings.
  terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
  skipped for X11 hosts (X11 is negotiated per channel).

Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:43:49 +08:00
陈大猫
2c96773679 Merge pull request #1207 from binaricat/fix/autocomplete-popup-overflow-1202
fix(autocomplete): 补全弹窗边界翻转 + 配色跟随强调色 (#1202)
2026-06-03 10:41:16 +08:00
bincxz
a8f9fd7a56 fix(autocomplete): make popup panels border-box for exact width clamp
Codex P2: the panel maxWidth constants used by the horizontal clamp's
totalWidth excluded each panel's padding + border (inline styles default to
content-box), so a padded detail tooltip at its 280px max could still render
wider than reserved and spill off-screen.

Set box-sizing: border-box on the shared panel style so every maxWidth is the
true outer width and totalWidth is exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:34:54 +08:00
陈大猫
aae4ad4da8 Merge pull request #1206 from binaricat/fix/topbar-ai-toggle-1194
顶部 AI 按钮支持 toggle 收起,移除无用的提醒按钮
2026-06-03 10:32:22 +08:00
bincxz
014d7b4d39 fix(autocomplete): reserve popup detail space per-set to avoid hover jump
Codex P2: totalWidth and the vertical height reservation depended on the
hovered/selected item's detail panel, so moving the mouse between a
no-detail row and a detail row could change the clamped position and shift
the popup under the pointer (flicker, unreliable clicks near the edge).

Reserve detail-panel width/height from a set-level flag (any non-path row
with a description) so placement stays stable while hovering; the tooltip
itself still renders only for the active row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:32:13 +08:00
陈大猫
5912339813 Merge pull request #1205 from binaricat/feat/alinux-distro-icon-1200
feat(distro): 新增 Alibaba Cloud Linux 发行版图标
2026-06-03 10:29:13 +08:00
bincxz
20bfa0e3bd fix(autocomplete): keep completion popup inside the window and follow accent
The terminal completion popup could spill past the window edges and ignored
the theme accent color (#1202):

- Horizontal: the left-edge clamp only reserved the main list width (400px),
  so expanding a directory near the right edge pushed the cascading sub-dir
  panels and the detail tooltip off-screen. Now the clamp accounts for the
  full assembly width (main list + sub-dir panels + detail tooltip) and pins
  to the left padding when it is wider than the viewport.
- Vertical: extracted the flip/clamp math into a pure, unit-tested
  computeAutocompletePopupPlacement() so "not enough room below -> flip up
  and bound the height" is verifiable. The detail tooltip is now height-
  bounded and scrolls instead of overflowing for long snippet descriptions.
- Accent: the selected/hover row background and the active-row accent rail now
  derive from the active terminal theme's cursor/selection colors (which track
  the user's accent setting) instead of a hardcoded blue.

Adds terminalAutocompleteLayout.test.ts covering downward/upward placement,
bottom-of-viewport flip, height clamping, and horizontal clamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:28:45 +08:00
bincxz
41ebe0fa64 Make top-bar AI button toggle the side panel and drop reminder button
The top-bar AI button previously dispatched netcatty:toggle-ai-panel,
which was wired to handleOpenAI -> handleSwitchSidePanelTab('ai'). That
switch is a no-op when AI is already the open sub-panel, so the panel
could not be dismissed by clicking the button again.

Add a dedicated handleToggleAiFromTopBar that routes through a new
resolveAiSidePanelToggleIntent helper: a second click on an already-open
AI panel closes the side panel, while a click from a closed panel or a
different sub-panel switches to AI. The top-bar event listener now uses
this toggle handler. handleOpenAI stays a plain switch so the AI icon in
the side-panel rail remains idempotent like the other rail tabs.

Also remove the non-functional reminder (bell) button from the top bar;
it had no click handler and no backing feature.

Refs #1194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:27:50 +08:00
bincxz
66cf610cc0 feat(distro): add Alibaba Cloud Linux (alinux) icon and detection
Alibaba Cloud Linux hosts (os-release ID="alinux") previously showed the
generic Linux icon because normalizeDistroId fell through to the catch-all
`linux` branch ('alinux'.includes('linux') is true).

- Add a dedicated `alinux` branch in normalizeDistroId, matching the
  os-release ID, the legacy `aliyun` ID, and the NAME/PRETTY_NAME text
  "Alibaba Cloud Linux"; place it before the generic linux fallback.
- Register `alinux` in LINUX_DISTRO_OPTIONS so it is selectable in the
  manual distro override and classified as linux-like.
- Add the brand SVG (public/distro/alinux.svg) plus logo/color mappings
  in DistroAvatar (brand color #FF6A00).
- Add the localized label in en / ru / zh-CN.
- Cover normalizeDistroId's alinux cases (ID, legacy aliyun, PRETTY_NAME)
  with unit tests, including a regression guard against the generic linux
  fallback.

Icon source: simple-icons "Alibaba Cloud" mark (CC0-1.0, public domain),
matching the existing distro icon set already sourced from simple-icons.

Closes #1200

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:24:44 +08:00
lateautumn233
62ec391523 Add ET integration checklist
Document the implementation status of EternalTerminal integration
across all layers (domain, electron, application, components, i18n)
with testing notes and known limitations.
2026-06-03 01:55:06 +08:00
lateautumn233
7927da2085 Build ET UI: host settings panel, protocol picker, and session starters
Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
2026-06-03 01:45:29 +08:00
lateautumn233
d45dea4bff Add ET localization strings for en and zh-CN
Terminal: protocol label, proxy-unsupported warning, multi-jump
limitation notice. Vault: ET settings section title, etPort and
etTerminalPath field labels with descriptions.
2026-06-03 01:45:28 +08:00
lateautumn233
816e274dfc Route ET protocol through application session state and backend
Resolve 'et' protocol in tray panel and host connection handlers with
priority over mosh. Propagate etEnabled through session factories and
useSessionState. Expose etAvailable guard and startEtSession wrapper
in useTerminalBackend.
2026-06-03 01:45:28 +08:00
lateautumn233
1a20a6a4a8 Expose startEtSession IPC channel via preload and bridge types
Add startEtSession to the preload API surface, routing through the
netcatty:et:start IPC channel. Define the startEtSession options type
in netcatty-bridge-session.d.ts with ET-specific parameters (etPort,
terminalPath, jumpHosts, etc.).
2026-06-03 01:45:28 +08:00
lateautumn233
910049b0ea Implement etSession process manager and bundled ET client resolver
Add etSession.cjs: full ET session lifecycle including SSH bootstrap with
host-key verification, etclient PTY spawning, temp directory management,
and external auth artifact cleanup. Wire the session API into
terminalBridge.cjs with IPC handler registration. Add bundledEtClient
resolver that locates the platform-specific et binary in both packaged
and dev environments. Include unit tests for both etSession and
bundledEtClient.
2026-06-03 01:45:28 +08:00
lateautumn233
173a83aafa Extend HostProtocol union and host models with ET fields
Add 'et' to HostProtocol union type. Add etEnabled, etPort, and
etTerminalPath fields to Host and GroupConfig interfaces. Update
vaultImport type guards to exclude 'et' from importable protocols.
Register ET fields in groupConfig inheritable keys.
2026-06-03 01:45:28 +08:00
陈大猫
b7093f88b1 Merge pull request #1203 from pyroch/main 2026-06-03 00:54:39 +08:00
pyroch
2e66bcf254 fix: hover feedback for window controls 2026-06-02 19:40:29 +03:00
lateautumn233
95208294b0 Bundle the prebuilt et client at pack time
Download and package the et binaries produced by build-et-binaries:

- resolve-et-bin-release picks the latest et-bin-* release; fetch-et-binaries
  downloads the platform client into resources/et/, verifying SHA256SUMS.
- et-extra-resources emits the electron-builder extraResources entry only
  when the binary is on disk, so pack still works without a bundled et.
- electron-builder.config.cjs wires et into the mac/win/linux bundles;
  package.json adds the fetch:et scripts.
2026-06-02 20:35:07 +08:00
lateautumn233
a4bf2234cd Add build-et-binaries workflow to compile the et client
Build the EternalTerminal `et` client in CI the way mosh-client is:

- build-et-binaries.yml builds et for linux x64/arm64, macOS universal
  and windows x64, uploads artifacts, and optionally publishes them to a
  dedicated binary repository on manual workflow_dispatch.
- scripts/build-et/{linux,macos,windows} compile et from upstream.
- .gitignore excludes the fetched binaries; resources/et/README.md
  documents source provenance.
2026-06-02 20:35:07 +08:00
陈大猫
e527e7233f Merge pull request #1197 from binaricat/codex/ssh-debug-logs
Add SSH debug logging setting
2026-06-02 12:05:00 +08:00
bincxz
afe959835d Add SSH debug log setting 2026-06-02 12:01:40 +08:00
陈大猫
3b2b05064b Merge pull request #1195 from binaricat/codex/fix-terminal-wheel-font-zoom
[codex] Fix terminal wheel font zoom capture
2026-06-02 10:49:52 +08:00
bincxz
1e94fe983f Fix terminal wheel font zoom capture 2026-06-02 10:46:37 +08:00
陈大猫
274ac4e0e1 Merge pull request #1192 from binaricat/codex/fix-catty-tool-result-repair
[codex] Fix Catty tool result history repair
2026-06-02 10:30:29 +08:00
bincxz
1ad4443e3b Fix Catty tool result history repair 2026-06-02 10:15:38 +08:00
陈大猫
031bf0ee45 Merge pull request #1188 from binaricat/codex/vault-sidebar-spacing
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
[codex] Balance vault sidebar spacing
2026-06-02 01:29:14 +08:00
bincxz
0efe80b06d Balance vault sidebar spacing 2026-06-02 01:28:39 +08:00
陈大猫
3fb7c6dd21 Merge pull request #1183 from pplulee/feature/codesnip-edit
feat: 优化代码片段脚本编辑
2026-06-02 01:17:36 +08:00
bincxz
c7e4ac82ca Fix snippet editor focus outline 2026-06-02 01:15:36 +08:00
bincxz
d5e29598d3 Merge main into PR 1183 and address review 2026-06-02 01:07:04 +08:00
陈大猫
fca7782634 Remove vault content left margin (#1187) 2026-06-02 01:00:19 +08:00
Pyro
42b23a9faa fix: blurry inline aside panel rendering (#1185) 2026-06-02 00:40:17 +08:00
Pyro
06011d01d6 fix: update RU localization (#1184) 2026-06-01 23:31:03 +08:00
pplulee
4bf4e65df8 fix(keychain): enhance key management UI with copy, edit, and delete actions (#1182) 2026-06-01 23:13:31 +08:00
pplulee
45e62ed43e feat(snippets): add SnippetScriptEditor component and update usage in dialogs 2026-06-01 23:05:56 +08:00
296 changed files with 21137 additions and 6216 deletions

222
.github/workflows/build-et-binaries.yml vendored Normal file
View File

@@ -0,0 +1,222 @@
name: build-et-binaries
# Trigger philosophy (mirrors build-mosh-binaries.yml):
# - Pushes that touch the et build pipeline + PRs run the matrix so we can
# validate workflow / script changes without tagging. Artifacts upload as
# workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the binaries +
# SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-et-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding the et
# binaries on every push.
on:
workflow_dispatch:
inputs:
et_ref:
description: "EternalTerminal git ref (tag/branch/commit) — see https://github.com/MisterTea/EternalTerminal"
type: string
default: "et-v6.2.10"
release_tag:
description: "Optional release tag to attach binaries to (e.g. et-bin-6.2.10-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores et binary releases."
type: string
default: "binaricat/Netcatty-et-bin"
push:
branches:
- "**"
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
pull_request:
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
concurrency:
group: build-et-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
ET_REF: ${{ inputs.et_ref || 'et-v6.2.10' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build et (linux-x64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build et (linux-arm64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo). Min deployment target macOS 11.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build et (darwin-universal)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-et/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 — static MSVC build (no DLL bundle).
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install ninja
run: choco install -y ninja
- name: Set up MSVC developer command prompt
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build et (win32-x64)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}\out
shell: pwsh
run: pwsh -File scripts/build-et/build-windows.ps1
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built until a tested client exists.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^et-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid et binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.ET_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::ET_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built EternalTerminal `et` client binaries consumed by `scripts/fetch-et-binaries.cjs` during `npm run pack`.'
printf 'Built from `MisterTea/EternalTerminal` upstream ref `%s`.\n\n' "${ET_REF}"
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are Apache-2.0; see `resources/et/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi

View File

@@ -29,6 +29,10 @@ on:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
et_bin_release:
description: "Release tag containing bundled et (EternalTerminal) binaries"
type: string
default: ""
push:
branches:
- "**"
@@ -54,6 +58,8 @@ permissions:
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
BUNDLE_ET: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
@@ -191,9 +197,38 @@ jobs:
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
resolve-et:
name: resolve bundled et-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
et_bin_release: ${{ steps.resolve.outputs.et_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled et-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-et-bin-release.cjs
release="$(grep '^ET_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::ET_BIN_RELEASE was not resolved."
exit 1
fi
echo "et_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -214,6 +249,7 @@ jobs:
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -230,6 +266,17 @@ jobs:
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -242,21 +289,6 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform.
# macOS packages still cover both arm64 and x64, so we need
# codex-acp for both architectures there.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
fi
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
@@ -267,6 +299,16 @@ jobs:
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:et -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:et -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
@@ -318,7 +360,7 @@ jobs:
# See #264.
build-linux-x64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -326,6 +368,7 @@ jobs:
runs-on: ubuntu-22.04
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -344,6 +387,17 @@ jobs:
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -379,6 +433,10 @@ jobs:
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=x64
- name: Build package
env:
npm_config_arch: x64
@@ -408,7 +466,7 @@ jobs:
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -418,6 +476,7 @@ jobs:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -436,6 +495,17 @@ jobs:
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
@@ -474,6 +544,10 @@ jobs:
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64

9
.gitignore vendored
View File

@@ -73,3 +73,12 @@ build_with_vs2022.bat
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/
# Bundled EternalTerminal `et` client binaries fetched at pack time by
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
# actual binaries (and any DLL bundle for dynamically-linked Windows builds)
# are pulled from the dedicated et binary repository, never committed.
/resources/et/*/et
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll

61
App.tsx
View File

@@ -36,6 +36,7 @@ import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLo
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
@@ -509,7 +510,12 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
// Install blocked because an editor has unsaved changes (#1215). The main
// process broadcasts this; show an actionable toast telling the user to save
// and click "Restart Now" again.
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
});
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
@@ -700,7 +706,7 @@ function App({ settings }: { settings: SettingsState }) {
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
// close flow.
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
const handleRequestCloseEditorTabRef = useRef<(id: string) => boolean | Promise<boolean>>(() => false);
const createLocalTerminalWithCurrentShell = useCallback(() => { return createLocalTerminalWithCurrentShellImpl(() => ({ classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings })); }, [createLocalTerminal, terminalSettings, discoveredShells]);
@@ -722,6 +728,12 @@ function App({ settings }: { settings: SettingsState }) {
const closeTabsInFlightRef = useRef(false);
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
const orderedTabsWithEditors = useMemo(
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
[orderedTabs, editorTabs],
);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
@@ -733,6 +745,43 @@ function App({ settings }: { settings: SettingsState }) {
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
const handleWindowCommandCloseRequest = useCallback(async () => {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
topmostDialogClose.click();
return;
}
const intent = resolveWindowCommandCloseIntent({
activeTabId,
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
sessionIds: sessions.map((session) => session.id),
workspaceIds: workspaces.map((workspace) => workspace.id),
logViewIds: logViews.map((logView) => logView.id),
});
if (intent.kind === 'closeTab') {
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
return;
}
if (intent.kind === 'closeLogView') {
closeLogView(intent.tabId);
return;
}
await netcattyBridge.get()?.windowClose?.();
}, [activeTabId, closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
useEffect(() => {
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
void handleWindowCommandCloseRequest();
});
return () => unsubscribe?.();
}, [handleWindowCommandCloseRequest]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
executeHotkeyAction(action, e);
@@ -936,13 +985,7 @@ function App({ settings }: { settings: SettingsState }) {
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
const orderedTabsWithEditors = useMemo(
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
[orderedTabs, editorTabs],
);
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
}
function AppWithProviders() {

216
ET_INTEGRATION_CHECKLIST.md Normal file
View File

@@ -0,0 +1,216 @@
# EternalTerminal (ET) 集成清单 — 按 Mosh 方式重做
> 目标:在上游最新架构(分支 `feat/et-history-reapply`,基于 `031bf0ee`)上,
> **完全照搬 Mosh 的方式**重新集成 EternalTerminal
> 1. **打包客户端** —— 像 `mosh-client` 那样,把 `et` 客户端二进制构建 + 下载 +
> 捆绑进安装包,运行时只用捆绑的二进制(不依赖系统安装的 et
> 2. **接入协议** —— 把旧分支 `feat/eternal-terminal`tip `67e81616`)里的 ET
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
## 关键设计差异ET vs Mosh
- **协议**Mosh 需要 Node 重写 Perl 包装器SSH bootstrap + 抓 `MOSH CONNECT` +
换 PTY。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
`et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`
- **凭证注入**Mosh 自己驱动 ssh、直接往 PTY 里敲密码ET 内部驱动 ssh需用
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
- **terminfo**`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`+ Windows DLL
- **构建系统**Mosh 用 autotools**ET 用 CMake + Ninja + vcpkg**
`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`
产物是单个 `et`Windows `et.exe`)。
## 命名约定(镜像 Mosh
| Mosh | ET |
|------|----|
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
| `scripts/build-mosh/` | `scripts/build-et/` |
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
---
## Phase 1 — 打包基础设施(构建/下载/捆绑)
- [x] **1.1** `resources/et/README.md` —— 镜像 `resources/mosh/README.md`:说明
二进制来源、`Netcatty-et-bin` 发布仓库、`et-bin-*` tag、许可证ET 为
Apache-2.0,与 GPL-3.0 兼容)、可复现构建命令。
- [x] **1.2** `.gitignore` —— 追加 ET 段(镜像 mosh 段):
`/resources/et/*/et``/resources/et/*/et.exe``/resources/et/*/*.dll`
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
构建 `et`x64/arm64产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`
校验非系统动态库(同 mosh 的 ldd 白名单)。
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
成 universal`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
`x64-windows-static`,产物 `et-win32-x64.tar.gz`(含 `et.exe`;若动态链接 CRT
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
指令(`to: "et/"`Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`env `ET_BIN_RELEASE` 优先。
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`
`TARGETS` 四项linux-x64/arm64、darwin-universal、win32-x64全部 tar.gz
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback。去掉 terminfo 校验。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`
`scripts/resolve-et-bin-release.test.cjs``scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
- [x] **1.10** `package.json` scripts新增
`"fetch:et": "node scripts/fetch-et-binaries.cjs"`
`"fetch:et:dev": "node scripts/fetch-et-binaries.cjs --host --resolve-release"`
`dev` 脚本改成先 `fetch:mosh:dev && fetch:et:dev``test` glob 已覆盖
`scripts/*.test.cjs`(确认即可)。
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` jobdispatch 且
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
指向 `scripts/build-et/**``scripts/fetch-et-binaries.cjs``scripts/et-extra-resources.cjs`
env 用 `ET_REF`(默认 ET release tag`et-v6.2.x`)。
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh
## Phase 2 — 运行时定位捆绑客户端
- [x] **2.1** `electron/bridges/terminalBridge.cjs` 新增 `bundledEtClient(opts)`
—— 镜像 `bundledMoshClient`:打包路径 `<Resources>/et/et[.exe]`dev 回退
`<projectRoot>/resources/et/<plat-arch>/et[.exe]`;导出到 module.exports。
## Phase 3 — ET 协议后端(搬运旧实现到新架构)
- [x] **3.1** 新建 `electron/bridges/terminalBridge/etSession.cjs` —— 用上游
`moshSession.cjs``createXxxSessionApi(ctx)` + `with(ctx)` 工厂模式,封装:
`ET_ASKPASS_SCRIPT``writeSecureFile``prepareEtSshEnvironment`
`createEtAskpassArtifacts``cleanupStaleEtTempDirs`
`cleanupSessionExternalAuthArtifacts``execOnEtSession``startEtSession`
**改动点**`etCmd``findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh提示跑 `npm run fetch:et:dev`)。
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx传入 `bundledEtClient``tempDirBridge``execFile/execFileSync` 等;
解构出 `startEtSession``execOnEtSession``cleanupStaleEtTempDirs`
`cleanupSessionExternalAuthArtifacts``resolveBareEtClient`
- [x] **3.3** `init()``cleanupStaleEtTempDirs()``registerHandlers`
`ipcMain.handle("netcatty:et:start", startEtSession)``closeSession`
`cleanupAllSessions``cleanupSessionExternalAuthArtifacts(session)`
`module.exports` 导出 `startEtSession``execOnEtSession``bundledEtClient`
- [x] **3.4** 测试:`terminalBridge.bundledEt.test.cjs`(路径解析)+
`terminalBridge/etSession.test.cjs`prepareEtSshEnvironment 的端口/密钥/
askpass/跳板/legacy 算法分支)。可参考旧分支是否已有 ET 测试并搬运。
## Phase 4 — domain / 类型 / preload 接口面
- [x] **4.1** `domain/models.ts``HostProtocol``'et'``ProtocolConfig.etPort?`
`Host`/`GroupConfig``etEnabled?`/`etPort?`/`etTerminalPath?`
`TerminalSession.etEnabled?``ConnectionLog.protocol``'et'`
(照搬 `git show 794eecdf -- domain/models.ts`
- [x] **4.2** `domain/groupConfig.ts`:加 `etEnabled` 默认项(照搬旧 diff
- [x] **4.3** `global.d.ts``NetcattyBridge``startEtSession?(options): Promise<...>`
及相关 options 类型(照搬 `git show 794eecdf -- global.d.ts`,并补齐后续 ET 提交
新增的 etPort/terminalPath/jumpHosts/legacyAlgorithms 字段)。
- [x] **4.4** `electron/preload/api.cjs`:加 `startEtSession`(镜像第 26 行的
`startMoshSession`)→ `ipcRenderer.invoke("netcatty:et:start", options)`
**注意**:上游已把 preload 重构成 `createPreloadApi`,落点在 `preload/api.cjs`
不是旧的 `preload.cjs` 内联对象。
## Phase 5 — 渲染层 + UI + i18n
- [x] **5.1** `application/state/useTerminalBackend.ts`:加 `etAvailable`(查
`bridge?.startEtSession`+ `startEtSession`,并在返回对象/依赖数组里登记
(镜像 mosh 的第 10/42/198/205 行处)。
- [x] **5.2** `application/state/useSessionState.ts`:路由 ET 会话(照搬旧 diff+6 行)。
- [x] **5.3** `components/terminal/runtime/createTerminalSessionStarters.ts`:加
`startEt(term)`(镜像 `startMosh`,组装 optionsetPort/terminalPath/
jumpHosts/legacyAlgorithms/凭证/identityFilePaths
**注意**:上游把它从旧的 `infrastructure/runtime/` 移到了
`components/terminal/runtime/` —— 落点以上游为准。
- [x] **5.4** UI 组件(照搬 `git show b1a306f8 6c0d5bf3 55caa268` 的相应文件,
映射到上游同名组件):
- [ ] `components/ProtocolSelectDialog.tsx` —— 新增 ET 选项
- [ ] `components/QuickConnectWizard.tsx`
- [ ] `components/HostDetailsPanel.tsx` —— ET 设置启用、ET 端口、etterminal 路径)
- [ ] `components/GroupDetailsPanel.tsx`
- [ ] `components/VaultView.tsx`
- [ ] `components/Terminal.tsx` / `components/TerminalLayer.tsx`
- [ ] `components/terminal/TerminalConnectionDialog.tsx` / `TerminalToolbar.tsx`
- [ ] `App.tsx`
- [x] **5.5** i18n`application/i18n/locales/en.ts``zh-CN.ts` 加 ET 文案
(照搬旧 diff键名对齐上游现有 mosh 文案结构)。
## Phase 6 — 校验
- [x] **6.1** `npm run lint`(确保新 .cjs 在 scripts/ 下不受 ESLint 限制,
或按需加 eslint-disable与 mosh 脚本一致)。
- [x] **6.2** `npm test`(新增的 fetch/resolve/extra-resources/etSession 测试全绿)。
- [x] **6.3** `npm run build`(渲染层 TS 编译通过,无类型错误)。
- [ ] **6.4** 手动冒烟(需先有发布的二进制):
`ET_BIN_RELEASE=et-bin-... npm run fetch:et``npm run start`
新建 ET 会话连一台装了 etserver 的主机,验证连接/输入/退出/凭证注入。
---
## 进度记录
- 状态:**Phase 15 已完成并通过校验**(仅余 1 个可选项 + CI 产二进制)
- 验证结果:
- `npx eslint <所有改动文件>` → 干净0 错 0 警)
- `npx tsc --noEmit` → 我的改动 **0 个新增类型错误**
`TerminalConnectionDialog``case 'mosh'` 的 TS2678 是既有问题,行号因我插入 ET 早返回从 60→64非新增
- `node --test`ET 相关)→ etSession/bundledEt/3 个脚本测试 **全绿**
- `npm test` → 1383 通过 / 16 失败,**16 个全是既有的 Windows 环境失败**
mosh 打包测试的 GNU-tar `C:` 问题、`isExecutableFile` 无 x 位、ACP execPath、SKILL.md 权限、Comware DH 等;均在我未改动的文件里)
- `npm run build`Vite**构建成功**8.55s),渲染层打包通过
### 已完成
- **Phase 1**`scripts/et-extra-resources.cjs` / `resolve-et-bin-release.cjs` /
`fetch-et-binaries.cjs`+3 测试27 通过)、`scripts/build-et/{build-linux.sh,
build-macos.sh,build-windows.ps1}``.github/workflows/build-et-binaries.yml`
`resources/et/README.md``.gitignore``package.json``electron-builder.config.cjs`
- **Phase 2**`terminalBridge.cjs` 新增并导出 `bundledEtClient`
- **Phase 3**`terminalBridge/etSession.cjs`startEtSession + prepareEtSshEnvironment +
SSH_ASKPASS 机制 + execOnEtSession + 清理),接线进 terminalBridge.cjsctx/IPC
`netcatty:et:start`/init 清理/close/quit 清理/导出),+2 测试13 通过)。
**et 指向捆绑二进制**resolveBareEtClient→bundledEtClient找不到则报错。
- **Phase 4**domain `connection.ts`/`history.ts`/`terminal.ts``groupConfig.ts`
`types/global/netcatty-bridge-session.d.ts`startEtSession + NetcattyJumpHost[])、
`electron/preload/api.cjs``domain/vaultImport.ts`(排除 'et' 导入协议)。
- **Phase 5**
- 启动派发:`useTerminalEffects.ts``Terminal.tsx`(×3) → `startEt`
- 运行时 starter`createTerminalSessionStarters.ts` 新增 `startEt`(含单跳板/凭证/
legacy 算法/askpass 路径),`.types.ts``etAvailable`/`startEtSession`
- 后端 hook`useTerminalBackend.ts`etAvailable + startEtSession
- 会话透传 etEnabled`sessionFactories.ts``useSessionState.ts`(×6)、
`TerminalLayer.tsx`(×3)、`TerminalLayerSupport.tsx``AppHandlers.ts`(协议解析/日志/选择)
- UI`HostDetailsAdvancedSections.tsx`ET 开关+端口+etterminal 路径,与 Mosh 互斥)、
`HostDetailsPanel.tsx``ProtocolSelectDialog.tsx`ET 选项)、
`TerminalConnectionDialog.tsx`ET 标签)、`TerminalToolbar.tsx`(编码菜单门控)、
`GroupSshSettingsSection.tsx` + `GroupDetailsPanel.tsx`(组级 ET`VaultView.tsx`
- i18nen/zh-CN 的 `hostDetails.section.et``hostDetails.et.*`
`terminal.connection.protocol.et``terminal.et.*`
### 剩余(可选 / 非阻塞)
- [ ] **QuickConnectWizard.tsx**:把 ET 加为“快速连接”协议按钮type/端口/建主机映射 +
UI 按钮)。当前快速连接未列 ET保存主机后开启 ET 再连即可,故仅为便利项。
- [ ] **产出二进制**:手动 `workflow_dispatch``build-et-binaries.yml`(带
`release_tag=et-bin-<ver>-1`)发布到 `Netcatty-et-bin`,并配 `ET_BIN_RELEASE_TOKEN`
secret。之后 `ET_BIN_RELEASE=... npm run fetch:et` 即可本地/打包捆绑 `et`
build-et 脚本本机无法编译 C++,需在 CI 验证。
- [ ] **端到端冒烟**:有二进制后 `npm run dev`,对装有 etserver 的主机建 ET 会话验证。
- 当前分支:`feat/et-history-reapply`(基于上游 `031bf0ee`
- 旧 ET 实现参考分支:`feat/eternal-terminal`tip `67e81616`7 个 ET 提交)

View File

@@ -73,7 +73,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
return;
}
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
@@ -82,7 +82,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -287,7 +287,7 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
@@ -633,7 +633,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
@@ -686,7 +686,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
return;
}
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
@@ -695,7 +695,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -766,9 +766,10 @@ export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: Hos
if (protocolSelectHost) {
const hostWithProtocol: Host = {
...protocolSelectHost,
protocol: protocol === 'mosh' ? 'ssh' : protocol,
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
port,
moshEnabled: protocol === 'mosh',
etEnabled: protocol === 'et',
};
handleConnectToHost(hostWithProtocol);
setProtocolSelectHost(null);

View File

@@ -43,7 +43,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
@@ -72,31 +72,34 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string) => {
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return;
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return;
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return;
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return;
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return;
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return;
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
@@ -269,6 +272,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
sshDebugLogsEnabled={sshDebugLogsEnabled}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>

View File

@@ -34,6 +34,10 @@ export const enAiMessages: Messages = {
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Context window',
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
@@ -49,7 +53,7 @@ export const enAiMessages: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
@@ -83,6 +87,9 @@ export const enAiMessages: Messages = {
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.settings': 'Settings file',
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
@@ -90,7 +97,7 @@ export const enAiMessages: Messages = {
// 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.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
@@ -105,7 +112,7 @@ export const enAiMessages: Messages = {
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
@@ -198,21 +205,21 @@ export const enAiMessages: Messages = {
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
'ai.safety.note': 'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',

View File

@@ -161,6 +161,17 @@ export const enCoreMessages: Messages = {
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH Debug Logs',
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
'settings.sshDebugLogs.location': 'Log Location',
'settings.sshDebugLogs.status': 'Status',
'settings.sshDebugLogs.statusOn': 'On',
'settings.sshDebugLogs.statusOff': 'Off',
'settings.sshDebugLogs.size': 'Size',
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
@@ -227,6 +238,8 @@ export const enCoreMessages: Messages = {
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.needsSave.title': 'Unsaved Changes',
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
@@ -394,6 +407,9 @@ export const enCoreMessages: Messages = {
'settings.terminal.localShell.shell.default': 'System Default',
'settings.terminal.localShell.shell.custom': 'Custom...',
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
'settings.terminal.localShell.shell.customArgs': 'Launch arguments',
'settings.terminal.localShell.shell.customArgs.placeholder': 'e.g. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
'settings.terminal.localShell.shell.pathValid': 'Path valid',
'settings.terminal.localShell.startDir': 'Starting directory',
@@ -557,12 +573,12 @@ export const enCoreMessages: Messages = {
'proxyProfiles.section.proxies': 'Proxies',
'proxyProfiles.count.items': '{count} items',
'proxyProfiles.empty.title': 'No Proxies',
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
'proxyProfiles.empty.desc': 'Create reusable HTTP, SOCKS5, or command proxies and select them from host details.',
'proxyProfiles.usage': '{count} linked',
'proxyProfiles.copyName': '{name} Copy',
'proxyProfiles.panel.newTitle': 'New Proxy',
'proxyProfiles.field.name': 'Proxy name',
'proxyProfiles.error.required': 'Name, host, and port are required.',
'proxyProfiles.error.required': 'Name and proxy details are required.',
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
'proxyProfiles.viewMode': 'Proxy view mode',
'proxyProfiles.delete.title': 'Delete proxy?',

View File

@@ -104,6 +104,9 @@ export const enTerminalMessages: Messages = {
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.',
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in Netcatty.',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.hostKey.unknownTitle': 'Confirm this host key',
@@ -514,6 +517,9 @@ export const enTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': 'Select or create package',
'snippets.field.createPackage': 'Create Package',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Open in dialog',
'snippets.scriptEditor.resize': 'Resize editor height',
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.history.title': 'Shell History',

View File

@@ -275,6 +275,7 @@ export const enVaultMessages: Messages = {
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWithDefault': 'Open with system default',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
@@ -471,6 +472,7 @@ export const enVaultMessages: Messages = {
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
@@ -483,6 +485,9 @@ export const enVaultMessages: Messages = {
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET server port',
'hostDetails.et.port.desc': 'Port etserver listens on (default 2022)',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
@@ -542,12 +547,15 @@ export const enVaultMessages: Messages = {
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Use %h for the target host, %p for the target port, and %% for a literal percent.',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
@@ -558,7 +566,7 @@ export const enVaultMessages: Messages = {
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
'hostDetails.envVars.title': 'Environment Variables',

View File

@@ -34,6 +34,10 @@ export const ruAiMessages: Messages = {
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Контекстное окно',
'ai.providers.contextWindow.placeholder': 'например, 128000',
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels': 'Обновить модели',
'ai.providers.searchModel': 'Искать или ввести ID модели...',
'ai.providers.filterModels': 'Фильтровать модели...',
@@ -49,7 +53,7 @@ export const ruAiMessages: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Использует codex + codex-acp для потоковой передачи по протоколу ACP. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.detecting': 'Обнаружение...',
'ai.codex.notFound': 'Не найден',
'ai.codex.awaitingLogin': 'Ожидание входа',
@@ -83,6 +87,9 @@ export const ruAiMessages: Messages = {
'ai.claude.configDir': 'Каталог конфигурации',
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.settings': 'Файл настроек',
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
'ai.claude.envVars': 'Переменные окружения',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
@@ -90,7 +97,7 @@ export const ruAiMessages: Messages = {
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Использует GitHub Copilot CLI через ACP по stdio (`copilot --acp --stdio`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
'ai.copilot.detecting': 'Обнаружение...',
'ai.copilot.detected': 'Обнаружен',
'ai.copilot.notFound': 'Не найден',
@@ -105,7 +112,7 @@ export const ruAiMessages: Messages = {
'ai.defaultAgent.catty': 'Catty (встроенный)',
'ai.toolAccess.title': 'Доступ к инструментам',
'ai.toolAccess.mode': 'Режим доступа Netcatty',
'ai.toolAccess.description': 'Выберите, как внешние ACP-агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'Пользовательские skills',
@@ -198,21 +205,21 @@ export const ruAiMessages: Messages = {
// AI Safety Settings
'ai.safety.title': 'Безопасность',
'ai.safety.permissionMode': 'Режим разрешений',
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к ACP-агентам. Режим подтверждения носит рекомендательный характер для ACP-агентов (они управляют собственным потоком одобрения инструментов).',
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
'ai.safety.permissionMode.autonomous': 'Автономный — выполнять свободно',
'ai.safety.commandTimeout': 'Тайм-аут команды',
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к ACP-агентам.',
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
'ai.safety.commandTimeout.unit': 'с',
'ai.safety.maxIterations': 'Макс. число итераций',
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У ACP-агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.blocklist': 'Чёрный список команд',
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к ACP-агентам через механизм выполнения Netcatty.',
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
'ai.safety.blocklist.add': 'Добавить шаблон',
'ai.safety.note': 'Чёрный список команд, тайм-аут команд и режим наблюдателя применяются на уровне MCP Server ко всем типам агентов. Режим подтверждения и максимальное число итераций полностью применяются к встроенному агенту; у ACP-агентов могут быть свои внутренние механизмы управления этими настройками.',
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Добавить терминал',

View File

@@ -161,6 +161,17 @@ export const ruCoreMessages: Messages = {
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
'settings.sshDebugLogs.location': 'Расположение журнала',
'settings.sshDebugLogs.status': 'Статус',
'settings.sshDebugLogs.statusOn': 'Включено',
'settings.sshDebugLogs.statusOff': 'Отключено',
'settings.sshDebugLogs.size': 'Размер',
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
'settings.globalHotkey.toggleWindow': 'Переключение окна',
@@ -227,6 +238,8 @@ export const ruCoreMessages: Messages = {
'update.restartNow': 'Перезапустить сейчас',
'update.downloadFailed.title': 'Ошибка обновления',
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
'update.needsSave.title': 'Несохранённые изменения',
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
'update.openReleases': 'Открыть релизы',
'update.remindLater': 'Напомнить позже',
'update.skipVersion': 'Пропустить эту версию',
@@ -394,6 +407,9 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
'settings.terminal.localShell.shell.customArgs': 'Аргументы запуска',
'settings.terminal.localShell.shell.customArgs.placeholder': 'напр. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
'settings.terminal.localShell.startDir': 'Начальный каталог',
@@ -594,12 +610,12 @@ export const ruCoreMessages: Messages = {
'proxyProfiles.section.proxies': 'Прокси',
'proxyProfiles.count.items': 'Элементов: {count}',
'proxyProfiles.empty.title': 'Нет прокси',
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP- или SOCKS5-прокси и выбирайте их в настройках хоста.',
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP-, SOCKS5- или командные прокси и выбирайте их в настройках хоста.',
'proxyProfiles.usage': 'Связано: {count}',
'proxyProfiles.copyName': '{name} Копия',
'proxyProfiles.panel.newTitle': 'Новый прокси',
'proxyProfiles.field.name': 'Имя прокси',
'proxyProfiles.error.required': 'Имя, хост и порт обязательны.',
'proxyProfiles.error.required': 'Имя и параметры прокси обязательны.',
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
'proxyProfiles.viewMode': 'Режим просмотра прокси',
'proxyProfiles.delete.title': 'Удалить прокси?',

View File

@@ -532,6 +532,20 @@ export const ruTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
'snippets.field.createPackage': 'Создать пакет',
'snippets.field.scriptRequired': 'Скрипт *',
'snippets.scriptEditor.expand': 'Открыть в окне',
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
'snippets.variables.dialogTitle': 'Переменные сниппета',
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
'snippets.variables.preview': 'Предпросмотр',
'snippets.variables.placeholder': 'Введите значение',
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
'snippets.variables.required': 'Эта переменная обязательна',
'snippets.variables.run': 'Запустить',
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
'snippets.field.variablesDetected': 'Переменные',
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.history.title': 'История оболочки',

View File

@@ -310,6 +310,7 @@ export const ruVaultMessages: Messages = {
// SFTP File Opener
'sftp.context.copyPath': 'Копировать путь к файлу',
'sftp.context.openWithDefault': 'Открыть в системном приложении',
'sftp.context.openWith': 'Открыть с помощью...',
'sftp.context.edit': 'Редактировать',
'sftp.context.preview': 'Предпросмотр',
@@ -506,6 +507,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
@@ -577,12 +579,15 @@ export const ruVaultMessages: Messages = {
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Напрямую',
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'Нет',
'hostDetails.proxy.edit': 'Редактировать прокси',
'hostDetails.proxy.configure': 'Настроить прокси',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Используйте %h для целевого хоста, %p для целевого порта и %% для символа процента.',
'hostDetails.proxyPanel.credentials': 'Учётные данные',
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
@@ -593,7 +598,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
'hostDetails.proxyPanel.missing': 'Отсутствует',
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
'hostDetails.proxyPanel.error.required': 'Прокси-хост и порт обязательны.',
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
'hostDetails.envVars': 'Переменные окружения',
'hostDetails.envVars.add': 'Добавить переменную окружения',
'hostDetails.envVars.title': 'Переменные окружения',

View File

@@ -34,6 +34,10 @@ export const zhCNAiMessages: Messages = {
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': '上下文窗口',
'ai.providers.contextWindow.placeholder': '例如 128000',
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值如果没有Netcatty 会使用安全默认值。',
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
@@ -49,7 +53,7 @@ export const zhCNAiMessages: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
@@ -83,6 +87,9 @@ export const zhCNAiMessages: Messages = {
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude留空用默认',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.settings': 'Settings 文件',
'ai.claude.settings.placeholder': '~/team-settings.json路径或内联 {"model":"..."}',
'ai.claude.settings.hint': '可选。settings.json 路径或内联 JSON作为 SDK 的 `settings` 传入。与上面的「配置目录」互补且独立(叠加合并,不是替换)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE传给 Claude agent。明文存在本地——API key凭据建议用上面的「配置目录」claude 登录),不要放这里。',
@@ -90,7 +97,7 @@ export const zhCNAiMessages: Messages = {
// 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.description': '接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
@@ -105,7 +112,7 @@ export const zhCNAiMessages: Messages = {
'ai.defaultAgent.catty': 'Catty内置',
'ai.toolAccess.title': '工具接入',
'ai.toolAccess.mode': 'Netcatty 接入模式',
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '用户 Skills',
@@ -198,21 +205,21 @@ export const zhCNAiMessages: Messages = {
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程。',
'ai.safety.permissionMode.description': '控制 AI 通过 Netcatty 访问终端会话的方式。观察者模式会阻止经由 Netcatty 的写操作;外部 Agent CLI 可能仍有自己的本机工具审批流程。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '命令执行的最秒数,超时将被终止。对内置和 ACP Agent 均生效。',
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最秒数,超时将被终止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.description': '用于拦截通过 Netcatty 执行的危险命令的正则表达式。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
'ai.safety.note': '这些安全设置会约束经由 Netcatty 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',

View File

@@ -145,6 +145,17 @@ export const zhCNCoreMessages: Messages = {
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 调试日志',
'settings.sshDebugLogs.enable': '启用 SSH 调试日志',
'settings.sshDebugLogs.enableDesc': '记录连接、认证、握手、断开和错误原因,不记录终端输出。',
'settings.sshDebugLogs.location': '日志位置',
'settings.sshDebugLogs.status': '状态',
'settings.sshDebugLogs.statusOn': '已开启',
'settings.sshDebugLogs.statusOff': '未开启',
'settings.sshDebugLogs.size': '大小',
'settings.sshDebugLogs.hint': '开启后,新发起的 SSH 连接会写入诊断信息,方便排查堡垒机、认证和异常断开问题。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全局快捷键',
'settings.globalHotkey.toggleWindow': '切换窗口',
@@ -211,6 +222,8 @@ export const zhCNCoreMessages: Messages = {
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.needsSave.title': '有未保存内容',
'update.needsSave.message': '请先保存已打开的编辑器,然后再次点击「立即重启」以安装更新。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
@@ -336,12 +349,12 @@ export const zhCNCoreMessages: Messages = {
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 项',
'proxyProfiles.empty.title': '暂无代理',
'proxyProfiles.empty.desc': '创建可复用的 HTTPSOCKS5 代理,然后在主机详情里选择。',
'proxyProfiles.empty.desc': '创建可复用的 HTTPSOCKS5 或命令代理,然后在主机详情里选择。',
'proxyProfiles.usage': '已关联 {count} 处',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名称',
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
'proxyProfiles.error.required': '名称和代理详情不能为空。',
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
'proxyProfiles.viewMode': '代理显示方式',
'proxyProfiles.delete.title': '删除代理?',

View File

@@ -1,6 +1,9 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -253,6 +256,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.shell.default': '系统默认',
'settings.terminal.localShell.shell.custom': '自定义...',
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
'settings.terminal.localShell.shell.customArgs': '启动参数',
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
'settings.terminal.localShell.shell.customArgs.desc': '传给 Shell 的启动参数。部分 Shell 必须指定才能正常工作,例如 msys2 bash 需要 --login -i 才能加载环境变量。',
'settings.terminal.localShell.shell.commonPaths': '常用路径',
'settings.terminal.localShell.shell.pathValid': '路径有效',
'settings.terminal.localShell.startDir': '起始目录',
@@ -340,8 +346,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目标主机,%p 表示目标端口,%% 表示字面百分号。',
'hostDetails.proxyPanel.credentials': '凭据',
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
@@ -352,7 +361,7 @@ export const zhCNTerminalMessages: Messages = {
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
@@ -495,6 +504,9 @@ export const zhCNTerminalMessages: Messages = {
'snippets.field.packagePlaceholder': '选择或创建代码包',
'snippets.field.createPackage': '创建代码包',
'snippets.field.scriptRequired': '脚本 *',
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.history.title': 'Shell 历史',

View File

@@ -65,6 +65,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': '阿里云 Linux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
@@ -77,6 +78,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.distro.option.ruijie': '锐捷',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET 服务端口',
'hostDetails.et.port.desc': 'etserver 监听端口(默认 2022',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.show': '显示密码',
@@ -136,7 +140,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -599,6 +603,7 @@ export const zhCNVaultMessages: Messages = {
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'sftp.context.openWithDefault': '系统默认程序打开',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',

View File

@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
});
});
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
@@ -131,7 +131,7 @@ test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use,
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses ACP continuity.
// orphaned list, otherwise the active chat loses external agent continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",

View File

@@ -23,7 +23,7 @@ import { emitAIStateChanged } from './aiStateEvents';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
@@ -42,11 +42,11 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupAcpSessions(sessionIds: string[]) {
export function cleanupSdkAgentSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiAcpCleanup(sessionId).catch(() => {});
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
}
}
@@ -86,7 +86,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {

View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAiSidePanelToggleIntent } from "./resolveAiSidePanelToggleIntent.ts";
test("close: AI panel already open → close the side panel", () => {
const r = resolveAiSidePanelToggleIntent("ai");
assert.deepEqual(r, { kind: "closeTerminalSidePanel" });
});
test("open: no panel open → open AI", () => {
const r = resolveAiSidePanelToggleIntent(null);
assert.deepEqual(r, { kind: "openAi" });
});
test("open: a different sub-panel is open → switch to AI", () => {
assert.deepEqual(resolveAiSidePanelToggleIntent("sftp"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("scripts"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("theme"), { kind: "openAi" });
});

View File

@@ -0,0 +1,19 @@
export type AiSidePanelToggleIntent =
| { kind: 'closeTerminalSidePanel' }
| { kind: 'openAi' };
/**
* Decide what the top-bar AI button should do given the side panel that is
* currently open for the active tab.
* - If the AI panel is already the open sub-panel → close the whole side panel.
* - Otherwise (closed, or showing a different sub-panel) → switch to AI.
*/
export function resolveAiSidePanelToggleIntent(
activePanel: string | null,
): AiSidePanelToggleIntent {
if (activePanel === 'ai') {
return { kind: 'closeTerminalSidePanel' };
}
return { kind: 'openAi' };
}

View File

@@ -84,6 +84,7 @@ export const createHostTerminalSession = (
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
};

View File

@@ -15,6 +15,7 @@ import {
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
@@ -51,6 +52,7 @@ interface UseSettingsIpcSyncParams {
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
@@ -78,6 +80,7 @@ export function useSettingsIpcSync({
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -164,6 +167,9 @@ export function useSettingsIpcSync({
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -216,6 +222,7 @@ export function useSettingsIpcSync({
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
setSftpTransferConcurrencyState,

View File

@@ -63,6 +63,7 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
export const DEFAULT_SESSION_LOGS_ENABLED = false;
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -155,4 +156,3 @@ export const applyThemeTokens = (
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};

View File

@@ -14,6 +14,7 @@ import {
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -73,6 +74,7 @@ interface UseSettingsStorageSyncParams {
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
@@ -103,6 +105,7 @@ interface UseSettingsStorageSyncParams {
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -118,7 +121,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -127,7 +130,7 @@ export function useSettingsStorageSync({
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -141,7 +144,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
@@ -151,7 +154,7 @@ export function useSettingsStorageSync({
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
};
@@ -302,6 +305,12 @@ export function useSettingsStorageSync({
setSessionLogsFormat(e.newValue);
}
}
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sshDebugLogsEnabled) {
setSshDebugLogsEnabled(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -387,6 +396,7 @@ export function useSettingsStorageSync({
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpAutoSync,
setSftpDefaultViewMode,

View File

@@ -71,7 +71,7 @@ export const useSftpConnections = ({
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -207,6 +207,11 @@ export const useSftpConnections = ({
isLocal: false,
status: "connecting",
currentPath: cachedStartPath,
// Suppress loading animation when connection reuse is requested.
// If the backend falls back to a fresh connection, the pane stays
// non-interactive (loading=true) with stale cached files visible —
// no worse than the previous UX of always showing a spinner.
reusedConnection: !!options?.sourceSessionId,
};
updateTab(side, activeTabId, (prev) => ({
@@ -292,6 +297,7 @@ export const useSftpConnections = ({
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
@@ -302,6 +308,7 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
@@ -317,6 +324,7 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
@@ -452,6 +460,7 @@ export const useSftpConnections = ({
status: "connected",
currentPath: startPath,
homeDir,
reusedConnection: undefined,
}
: null,
files,

View File

@@ -2,6 +2,7 @@ import { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
import { joinPath } from "./utils";
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
import {
@@ -178,27 +179,24 @@ export const useSftpExternalOperations = (
[getPaneByConnectionId, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
const downloadToTemp = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
): Promise<{ localTempPath: string; sftpId: string; externalTransferId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("System app opening not supported");
if (!bridge?.downloadSftpToTemp) {
throw new Error("SFTP temp download not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
throw new Error("Temp download is only available for remote files");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
@@ -287,12 +285,12 @@ export const useSftpExternalOperations = (
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
updateExternalUpload(externalTransferId, {
@@ -311,7 +309,7 @@ export const useSftpExternalOperations = (
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
if (bridge.registerTempFile) {
@@ -322,11 +320,44 @@ export const useSftpExternalOperations = (
}
}
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const { localTempPath, sftpId, externalTransferId } = await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) {
return { localTempPath: "" };
}
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
updateExternalUpload?.(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
@@ -354,7 +385,67 @@ export const useSftpExternalOperations = (
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
[downloadToTemp, getActivePane, updateExternalUpload],
);
const openWithSystemDefault = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
options?: { enableWatch?: boolean }
): Promise<void> => {
try {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithSystemDefault) {
throw new Error("System default opening not supported");
}
const bridgeMethods = bridge;
const { localTempPath, sftpId, externalTransferId } = pane.connection.isLocal
? { localTempPath: remotePath, sftpId: "", externalTransferId: undefined }
: await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) return;
const result = await bridgeMethods.openWithSystemDefault(localTempPath);
if (!result.success) {
if (externalTransferId) {
updateExternalUpload?.(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: result.error || "Failed to open file",
speed: 0,
});
}
throw new Error(result.error || "Failed to open file");
}
// Start file watch for remote SFTP auto-sync (mirrors downloadToTempAndOpen behavior)
if (options?.enableWatch && !pane.connection.isLocal && bridgeMethods.startFileWatch) {
try {
await bridgeMethods.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch for default app open:", err);
}
}
} catch (err) {
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[downloadToTemp, getActivePane, updateExternalUpload],
);
// Create upload callbacks that translate to TransferTask updates
@@ -914,6 +1005,7 @@ export const useSftpExternalOperations = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,

View File

@@ -36,6 +36,7 @@ export interface SftpExternalOperationsResult {
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
@@ -62,4 +63,3 @@ export interface SftpExternalOperationsResult {
uploadConflicts: FileConflict[];
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
}

View File

@@ -52,6 +52,31 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
);
});
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
const credentials = buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h",
},
}),
hosts: [],
keys: [],
identities: [],
});
assert.deepEqual(credentials.proxy, {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h",
username: undefined,
password: undefined,
});
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",

View File

@@ -3,6 +3,7 @@ import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/m
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
@@ -36,6 +37,7 @@ export const buildSftpHostCredentials = ({
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
command: host.proxyConfig.command,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
@@ -69,7 +71,7 @@ export const buildSftpHostCredentials = ({
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
@@ -101,11 +103,12 @@ export const buildSftpHostCredentials = ({
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
command: jumpHost.proxyConfig.command,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../../domain/models";
import {
canReuseTerminalConnection,
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from "./terminalConnectionReuse";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Host",
hostname: "example.com",
username: "alice",
status: "connected",
protocol: "ssh",
...overrides,
});
test("connected SSH sessions can reuse their authenticated connection", () => {
assert.equal(canReuseTerminalConnection(session()), true);
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
});
test("non-SSH or unavailable sessions do not reuse a connection", () => {
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "local" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "serial" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "telnet" })), false);
assert.equal(canReuseTerminalConnection(session({ moshEnabled: true })), false);
assert.equal(canReuseTerminalConnection(session({ etEnabled: true })), false);
});
test("split session clones reuse only connected SSH sources", () => {
assert.equal(
createSplitTerminalSessionClone(session(), { id: "split-1", workspaceId: "workspace-1" }).reuseConnectionFromSessionId,
"session-1",
);
assert.equal(
createSplitTerminalSessionClone(session({ etEnabled: true }), { id: "split-2" }).reuseConnectionFromSessionId,
undefined,
);
assert.equal(
createSplitTerminalSessionClone(session({ moshEnabled: true }), { id: "split-3" }).reuseConnectionFromSessionId,
undefined,
);
});
test("copy session clones reuse SSH sources and preserve serial config", () => {
const copied = createCopiedTerminalSessionClone(
session({
serialConfig: { path: "/dev/tty.usbserial", baudRate: 115200 },
}),
{ id: "copy-1" },
);
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});

View File

@@ -0,0 +1,71 @@
import type { TerminalSession } from "../../domain/models";
export function canReuseTerminalConnection(session: TerminalSession): boolean {
return (
(session.protocol === "ssh" || session.protocol === undefined) &&
!session.moshEnabled &&
!session.etEnabled &&
session.status === "connected"
);
}
type CloneSessionOptions = {
id: string;
localShellType?: TerminalSession["shellType"];
workspaceId?: string;
};
function getClonedShellType(
session: TerminalSession,
localShellType?: TerminalSession["shellType"],
): TerminalSession["shellType"] {
return session.protocol === "local" ? localShellType : session.shellType;
}
function createTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
const clonedSession: TerminalSession = {
id: options.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: "connecting",
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
shellType: getClonedShellType(session, options.localShellType),
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};
if (options.workspaceId) {
clonedSession.workspaceId = options.workspaceId;
}
return clonedSession;
}
export function createSplitTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
return createTerminalSessionClone(session, options);
}
export function createCopiedTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
return {
...createTerminalSessionClone(session, options),
serialConfig: session.serialConfig,
};
}

View File

@@ -139,6 +139,101 @@ test("uploads picked folder files with their relative directory structure", asyn
]);
});
test("uploads path-backed clipboard files through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
const taskTotals: number[] = [];
const results = await uploadEntriesDirect(
[
{
file: null,
localPath: "/Users/me/Desktop/report.txt",
relativePath: "report.txt",
isDirectory: false,
size: 42,
},
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transfers.push({
sourcePath: payload.sourcePath,
targetPath: payload.targetPath,
totalBytes: payload.totalBytes,
});
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => taskTotals.push(task.totalBytes),
},
},
);
assert.deepEqual(taskTotals, [42]);
assert.deepEqual(transfers, [
{
sourcePath: "/Users/me/Desktop/report.txt",
targetPath: "/target/report.txt",
totalBytes: 42,
},
]);
assert.deepEqual(results, [
{ fileName: "report.txt", success: true },
]);
});
test("copies path-backed clipboard files into local panes through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; targetType: string; totalBytes?: number }> = [];
const results = await uploadEntriesDirect(
[
{
file: null,
localPath: "/Users/me/Desktop/report.txt",
relativePath: "report.txt",
isDirectory: false,
size: 42,
},
],
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirLocal: async () => {},
startStreamTransfer: async (payload) => {
transfers.push({
sourcePath: payload.sourcePath,
targetPath: payload.targetPath,
targetType: payload.targetType,
totalBytes: payload.totalBytes,
});
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(transfers, [
{
sourcePath: "/Users/me/Desktop/report.txt",
targetPath: "/target/report.txt",
targetType: "local",
totalBytes: 42,
},
]);
assert.deepEqual(results, [
{ fileName: "report.txt", success: true },
]);
});
test("reports empty directory creation failures", async () => {
const madeDirs: string[] = [];

View File

@@ -46,7 +46,7 @@ import {
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
bumpDraftMutationVersion,
bumpDraftUploadGeneration,
cleanupAcpSessions,
cleanupSdkAgentSessions,
cleanupOrphanedAISessions,
getAIBridge,
getDraftUploadGeneration,
@@ -321,7 +321,7 @@ export function useAIState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
// Sync to MCP Server bridge so ACP agents also respect the blocklist
// Sync to MCP Server bridge so SDK agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
}, []);
@@ -337,7 +337,7 @@ export function useAIState() {
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
// Sync to MCP Server bridge (used by ACP agent path)
// Sync to MCP Server bridge (used by SDK agent path)
const bridge = getAIBridge();
bridge?.aiMcpSetMaxIterations?.(value);
}, []);
@@ -571,7 +571,7 @@ export function useAIState() {
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupAcpSessions([sessionId]);
cleanupSdkAgentSessions([sessionId]);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -600,7 +600,7 @@ export function useAIState() {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
cleanupSdkAgentSessions(removedSessionIds);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
@@ -52,19 +53,23 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args, ACP config, or Claude's resolved system path differ
// Check if args, SDK backend, or Claude's resolved system path differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
const backend = match.sdkBackend ?? match.command;
const backendChanged = getExternalAgentSdkBackend(ea) !== backend
|| Boolean(ea.acpCommand)
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify([]);
const matchPath = match.binPath || match.path;
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
if (currentArgs !== newArgs || acpChanged || envChanged) {
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
if (currentArgs !== newArgs || backendChanged || envChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
return { ...rest, args: match.args, sdkBackend: backend, ...(env ? { env } : {}) };
}
return ea;
});
@@ -82,16 +87,18 @@ export function useAgentDiscovery(
// Build ExternalAgentConfig from a discovered agent
const enableAgent = useCallback(
(agent: DiscoveredAgent): ExternalAgentConfig => {
const backend = agent.sdkBackend ?? agent.command;
return {
id: `discovered_${agent.command}`,
name: agent.name,
command: agent.path || agent.command,
command: agent.binPath || agent.path || agent.command,
args: agent.args,
icon: agent.icon,
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
sdkBackend: backend,
...(agent.command === 'claude'
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
: {}),
};
},
[],

View File

@@ -17,6 +17,10 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { activeTabStore } from './activeTabStore';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from './terminalConnectionReuse';
export const useSessionState = () => {
@@ -286,6 +290,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
@@ -372,6 +377,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
@@ -475,6 +481,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
workspaceId,
};
@@ -574,31 +581,15 @@ export const useSessionState = () => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
// Create a new session with the same host
const newSession: TerminalSession = {
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
});
// Add pane to existing workspace
const hint: SplitHint = {
@@ -618,23 +609,10 @@ export const useSessionState = () => {
}
// Session is standalone - create a new workspace
const newSession: TerminalSession = {
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
@@ -802,28 +780,10 @@ export const useSessionState = () => {
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
const newSession: TerminalSession = {
const newSession = createCopiedTerminalSessionClone(session, {
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
localShellType: options?.localShellType,
});
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so

View File

@@ -31,6 +31,7 @@ import {
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
@@ -77,6 +78,7 @@ import {
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
applyThemeTokens,
@@ -240,6 +242,10 @@ export const useSettingsState = () => {
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -460,6 +466,12 @@ export const useSettingsState = () => {
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SSH diagnostics
const storedSshDebugLogsEnabled = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
}
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
@@ -552,6 +564,7 @@ export const useSettingsState = () => {
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -587,7 +600,7 @@ export const useSettingsState = () => {
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -596,7 +609,7 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -779,6 +792,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
}, [sshDebugLogsEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
@@ -940,6 +959,8 @@ export const useSettingsState = () => {
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
@@ -963,7 +984,7 @@ export const useSettingsState = () => {
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
customThemes, workspaceFocusStyle, sshDebugLogsEnabled,
]),
};
};

View File

@@ -304,6 +304,7 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
@@ -383,6 +384,7 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
@@ -440,6 +442,7 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
@@ -507,6 +510,8 @@ export const useSftpState = (
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
methodsRef.current.writeTextFileByConnection(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
openWithSystemDefault: (...args: Parameters<typeof openWithSystemDefault>) =>
methodsRef.current.openWithSystemDefault(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
methodsRef.current.uploadExternalFileList(...args),

View File

@@ -12,6 +12,11 @@ export const useTerminalBackend = () => {
return !!bridge?.startMoshSession;
}, []);
const etAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startEtSession;
}, []);
const localAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startLocalSession;
@@ -45,6 +50,12 @@ export const useTerminalBackend = () => {
return bridge.startMoshSession(options);
}, []);
const startEtSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startEtSession) throw new Error("startEtSession unavailable");
return bridge.startEtSession(options);
}, []);
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
@@ -116,6 +127,11 @@ export const useTerminalBackend = () => {
return bridge?.onChainProgress?.(cb);
}, []);
const onConnectionReuseFallback = useCallback((cb: (sessionId: string, sourceSessionId?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onConnectionReuseFallback?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
@@ -196,6 +212,7 @@ export const useTerminalBackend = () => {
backendAvailable,
telnetAvailable,
moshAvailable,
etAvailable,
localAvailable,
serialAvailable,
execAvailable,
@@ -203,6 +220,7 @@ export const useTerminalBackend = () => {
startSSHSession,
startTelnetSession,
startMoshSession,
startEtSession,
startLocalSession,
startSerialSession,
listSerialPorts,
@@ -221,6 +239,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
@@ -229,6 +248,7 @@ export const useTerminalBackend = () => {
backendAvailable,
telnetAvailable,
moshAvailable,
etAvailable,
localAvailable,
serialAvailable,
execAvailable,
@@ -236,6 +256,7 @@ export const useTerminalBackend = () => {
startSSHSession,
startTelnetSession,
startMoshSession,
startEtSession,
startLocalSession,
startSerialSession,
listSerialPorts,
@@ -254,6 +275,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,

View File

@@ -53,13 +53,20 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
const autoUpdateEnabled = options?.autoUpdateEnabled ??
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
// Latest "install blocked by unsaved editors" callback (#1215). Kept in a ref
// so the listener effect (empty deps) always calls the current one without
// re-subscribing on every render. The consuming component shows the toast;
// this hook only owns the bridge subscription (toasts live in the view layer).
const onNeedsSaveRef = useRef(options?.onNeedsSave);
onNeedsSaveRef.current = options?.onNeedsSave;
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
@@ -252,12 +259,22 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
}));
});
// Install was requested but blocked by unsaved editors (#1215). The main
// process broadcasts this to every window so whichever one the user clicked
// "Restart Now" from gets feedback. Delegate to the caller's handler (which
// shows the toast) — registered here because bridge subscriptions belong in
// the state layer, not in components.
const cleanupNeedsSave = bridge?.onUpdateNeedsSave?.(() => {
onNeedsSaveRef.current?.();
});
return () => {
cleanupNotAvailable?.();
cleanupAvailable?.();
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
cleanupNeedsSave?.();
};
}, []);

View File

@@ -50,6 +50,11 @@ export const useWindowControls = () => {
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
}, []);
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowCommandCloseRequested?.(cb) ?? (() => {});
}, []);
return {
notifyRendererReady,
closeSettingsWindow,
@@ -60,5 +65,6 @@ export const useWindowControls = () => {
isMaximized,
isFullscreen,
onFullscreenChanged,
onWindowCommandCloseRequested,
};
};

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
test("Cmd+W closes the active closable tab first", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "s1",
editorTabIds: [],
sessionIds: ["s1", "s2"],
workspaceIds: [],
logViewIds: [],
}),
{ kind: "closeTab" },
);
});
test("Cmd+W on a log view closes the log view", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "log-1",
editorTabIds: [],
sessionIds: ["s1", "s2"],
workspaceIds: [],
logViewIds: ["log-1"],
}),
{ kind: "closeLogView", tabId: "log-1" },
);
});
test("Cmd+W closes an editor tab through the existing close flow", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "editor:1",
editorTabIds: ["editor:1"],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}),
{ kind: "closeTab" },
);
});
test("Cmd+W closes the window from the Vault page", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "vault",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}),
{ kind: "closeWindow" },
);
});
test("Cmd+W closes the window when nothing else is active", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: null,
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}),
{ kind: "closeWindow" },
);
});

View File

@@ -0,0 +1,42 @@
export type WindowCommandCloseIntent =
| { kind: 'closeTab' }
| { kind: 'closeLogView'; tabId: string }
| { kind: 'closeWindow' };
interface ResolveWindowCommandCloseIntentInput {
activeTabId: string | null;
editorTabIds: string[];
sessionIds: string[];
workspaceIds: string[];
logViewIds: string[];
}
export function resolveWindowCommandCloseIntent({
activeTabId,
editorTabIds,
sessionIds,
workspaceIds,
logViewIds,
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
if (!activeTabId) {
return { kind: 'closeWindow' };
}
if (editorTabIds.includes(activeTabId)) {
return { kind: 'closeTab' };
}
if (sessionIds.includes(activeTabId) || workspaceIds.includes(activeTabId)) {
return { kind: 'closeTab' };
}
if (logViewIds.includes(activeTabId)) {
return { kind: 'closeLogView', tabId: activeTabId };
}
if (activeTabId === 'vault' || activeTabId === 'sftp') {
return { kind: 'closeWindow' };
}
return { kind: 'closeWindow' };
}

View File

@@ -12,7 +12,7 @@ import type {
} from '../infrastructure/ai/types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
@@ -37,7 +37,7 @@ import {
getNetcattyBridge,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
@@ -480,16 +480,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
if (!bridge?.aiSdkAgentListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
void bridge.aiSdkAgentListModels(
sdkBackend,
undefined,
undefined,
`models_${currentAgentId}`,
@@ -515,7 +515,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
});
@@ -744,7 +744,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
terminalSessions,
defaultTargetSession,
providers,
@@ -811,7 +811,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
clearAllPendingApprovals(activeSessionId);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiAcpCancel?.('', activeSessionId);
bridge?.aiSdkAgentCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(

View File

@@ -1,4 +1,5 @@
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
if (preset.thinkingLevels?.length) {
@@ -18,7 +19,7 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());

View File

@@ -18,6 +18,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
alinux: "/distro/alinux.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
@@ -48,6 +49,7 @@ export const DISTRO_COLORS: Record<string, string> = {
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
alinux: "bg-[#FF6A00]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",

View File

@@ -13,7 +13,12 @@ import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { customThemeStore } from "../application/state/customThemeStore";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
} from "../domain/proxyProfiles";
import {
EnvVar,
GroupConfig,
@@ -103,6 +108,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
const hasTelnetFields = (c: Partial<GroupConfig>) =>
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
@@ -137,7 +143,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -171,6 +177,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.protocol;
delete next.moshEnabled;
delete next.moshServerPath;
delete next.etEnabled;
delete next.etPort;
return next;
});
};
@@ -391,6 +399,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
...(form.moshServerPath !== undefined && { moshServerPath: form.moshServerPath }),
...(form.etEnabled !== undefined && { etEnabled: form.etEnabled }),
...(form.etPort !== undefined && { etPort: form.etPort }),
}),
// Only include Telnet fields if Telnet section is enabled
...(telnetEnabled && {

View File

@@ -449,7 +449,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
<span className="text-sm">{t("hostDetails.proxy")}</span>
</div>
<div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && (
{(form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 cursor-default">
@@ -523,6 +523,25 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
/>
)}
{/* EternalTerminal */}
<ToggleRow
label="EternalTerminal"
enabled={!!form.etEnabled}
onToggle={() => update("etEnabled", !form.etEnabled)}
/>
{form.etEnabled && (
<Input
type="number"
placeholder={t("hostDetails.et.port") || "ET server port (2022)"}
value={form.etPort ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
update("etPort", v === "" ? undefined : Number(v));
}}
className="h-10"
/>
)}
{/* Backspace behavior — terminal input mapping, lives at the
bottom of the SSH section so it doesn't get visually
grouped with the algorithm controls above. */}

View File

@@ -175,6 +175,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
setForm(prev => ({
...prev,
moshEnabled: true,
etEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
@@ -185,6 +186,46 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
</HostDetailsSection>
<HostDetailsSection
icon={<Wifi size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.et")}
>
<ToggleRow
label="EternalTerminal"
enabled={!!form.etEnabled}
onToggle={() => {
const enabling = !form.etEnabled;
if (enabling) {
setForm(prev => ({
...prev,
etEnabled: true,
moshEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
} else {
update("etEnabled", false);
}
}}
/>
{form.etEnabled && (
<>
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
<Input
type="number"
className="w-28"
placeholder="2022"
value={form.etPort ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
update("etPort", v === "" ? undefined : Number(v));
}}
/>
</HostDetailsSettingRow>
</>
)}
</HostDetailsSection>
{/* Agent Forwarding */}
<HostDetailsSection
icon={<Forward size={14} className="text-muted-foreground" />}
@@ -212,7 +253,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")}
@@ -226,8 +267,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
@@ -481,7 +522,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.proxy")}
className="overflow-hidden"
>
{form.proxyConfig?.host || form.proxyProfileId ? (
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"

View File

@@ -5,6 +5,40 @@ import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
export const parseOptionalPortInput = (value: string): number | undefined =>
value ? Number(value) : undefined;
export const resolvePrimaryProtocolSwitchPort = (
currentPort: number | undefined,
nextProtocol: "ssh" | "telnet",
hasGroupTelnetPortDefault: boolean,
hasGroupSshPortDefault: boolean,
): number | undefined => {
if (nextProtocol === "telnet") {
// Don't override if group provides a Telnet default
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return currentPort;
if (currentPort === 22 || currentPort === undefined) return 23;
return currentPort;
}
if (nextProtocol === "ssh") {
if (hasGroupSshPortDefault) return currentPort;
if (currentPort === 23 || currentPort === undefined) return 22;
return currentPort;
}
return currentPort;
};
export const resolvePrimaryProtocolSavePort = (
protocol: Host["protocol"],
currentPort: number | undefined,
hasGroupSshPortDefault: boolean,
hasGroupTelnetPortDefault: boolean,
): number | undefined => {
if (protocol === "telnet") {
if (currentPort !== undefined) return currentPort;
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return undefined;
return 23;
}
return currentPort ?? (hasGroupSshPortDefault ? undefined : 22);
};
export const resolveDetailsTelnetPort = (
host: Host,
groupDefaults?: Partial<GroupConfig>,

View File

@@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import {
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
const hostWithMissingProxyProfile: Host = {
@@ -67,6 +71,24 @@ test("HostDetailsPanel shows a missing saved proxy without undefined fields", ()
assert.doesNotMatch(markup, /undefined:undefined/);
});
test("HostDetailsPanel labels command proxy summaries consistently", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
proxyConfig: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h --token secret",
},
});
assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /COMMAND/);
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
});
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
@@ -241,6 +263,26 @@ test("parseOptionalPortInput clears empty port values", () => {
assert.equal(parseOptionalPortInput("2325"), 2325);
});
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
assert.equal(resolvePrimaryProtocolSwitchPort(2222, "telnet", false, false), 2222);
assert.equal(resolvePrimaryProtocolSwitchPort(2323, "ssh", false, false), 2323);
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "telnet", false, false), 23);
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "ssh", false, false), 22);
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, true), 22);
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", true, false), 22);
});
test("resolvePrimaryProtocolSavePort falls back to telnet default for primary telnet", () => {
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, false), 23);
assert.equal(resolvePrimaryProtocolSavePort("telnet", 2323, false, false), 2323);
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, false, false), 22);
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, true, false), undefined);
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, true), undefined);
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, true, false), undefined);
});
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,

View File

@@ -17,7 +17,12 @@ import {
getEffectiveHostDistro,
normalizePrimaryTelnetState,
} from "../domain/host";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
} from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
hasHostFontSizeOverride,
@@ -36,7 +41,15 @@ import {
} from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import { LINUX_DISTRO_OPTION_IDS, parseOptionalPortInput, resolveDetailsTelnetPassword, resolveDetailsTelnetPort, resolveDetailsTelnetUsername } from "./HostDetailsPanel.helpers";
import {
LINUX_DISTRO_OPTION_IDS,
parseOptionalPortInput,
resolveDetailsTelnetPassword,
resolveDetailsTelnetPort,
resolveDetailsTelnetUsername,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { Button } from "./ui/button";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
@@ -246,17 +259,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
: formatProxyConfigEndpoint(form.proxyConfig);
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -385,10 +398,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
const finalPort =
form.protocol === "telnet"
? form.port
: form.port ?? (groupDefaults?.port ? undefined : 22);
const finalPort = resolvePrimaryProtocolSavePort(
form.protocol,
form.port,
Boolean(groupDefaults?.port),
Boolean(groupDefaults?.telnetPort),
);
let cleaned: Host = {
...formWithoutProxyDraft,
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
@@ -443,7 +458,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
cleaned.fontSize = initialData?.fontSize;
}
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
delete cleaned.x11Forwarding;
}
onSave(cleaned);
@@ -873,7 +888,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
<Switch
checked={form.protocol === "telnet"}
onCheckedChange={(checked) => update("protocol", checked ? "telnet" : "ssh")}
onCheckedChange={(checked) => {
const nextProtocol = checked ? "telnet" : "ssh";
setForm((prev) => ({
...prev,
protocol: nextProtocol,
port: resolvePrimaryProtocolSwitchPort(
prev.port,
nextProtocol,
Boolean(groupDefaults?.telnetPort),
Boolean(groupDefaults?.port),
),
}));
}}
/>
</div>

View File

@@ -1,11 +1,12 @@
import {
BadgeCheck,
ChevronDown,
Copy,
Edit2,
ExternalLink,
Key,
LayoutGrid,
List as ListIcon,
MoreHorizontal,
Plus,
Shield,
Trash2,
@@ -838,9 +839,35 @@ echo $3 >> "$FILE"`);
</AsideActionMenuItem>
</AsideActionMenu>
) : panel.type === "view" ? (
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={16} />
</Button>
<AsideActionMenu>
{panel.key.publicKey ? (
<AsideActionMenuItem
icon={<Copy size={14} />}
onClick={() => copyPublicKey(panel.key)}
>
{t("action.copyPublicKey")}
</AsideActionMenuItem>
) : null}
<AsideActionMenuItem
icon={<ExternalLink size={14} />}
onClick={() => openKeyExport(panel.key)}
>
{t("action.keyExport")}
</AsideActionMenuItem>
<AsideActionMenuItem
icon={<Edit2 size={14} />}
onClick={() => openKeyEdit(panel.key)}
>
{t("action.edit")}
</AsideActionMenuItem>
<AsideActionMenuItem
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => handleDelete(panel.key.id)}
>
{t("action.delete")}
</AsideActionMenuItem>
</AsideActionMenu>
) : undefined
}
>

View File

@@ -63,6 +63,18 @@ const ProtocolSelectDialog: React.FC<ProtocolSelectDialogProps> = ({
});
}
// EternalTerminal (if enabled)
if (host.etEnabled || host.protocols?.some(p => p.protocol === 'et' && p.enabled)) {
options.push({
protocol: 'et',
port: host.port || 22,
label: 'EternalTerminal',
icon: <Wifi size={18} />,
description: `et ${host.hostname}`,
enabled: true,
});
}
// Telnet (if enabled)
if (host.telnetEnabled || host.protocol === 'telnet' || host.protocols?.some(p => p.protocol === 'telnet' && p.enabled)) {
const telnetConfig = host.protocols?.find(p => p.protocol === 'telnet');

View File

@@ -49,6 +49,29 @@ test("ProxyPanel shows saved proxy selection when reusable profiles exist", () =
assert.doesNotMatch(markup, /Proxy host/);
});
test("ProxyPanel labels saved ProxyCommand profiles without showing command contents", () => {
const commandProxy: ProxyProfile = {
id: "proxy-command-1",
label: "Cloudflare Access",
config: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h --token secret",
},
createdAt: 1,
};
const markup = renderPanel({
proxyProfiles: [commandProxy],
selectedProxyProfileId: commandProxy.id,
});
assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /COMMAND/);
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
});
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
@@ -78,3 +101,29 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
assert.match(markup, /Port must be between 1 and 65535/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel supports custom ProxyCommand settings", () => {
const markup = renderPanel({
proxyConfig: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h",
},
});
assert.match(markup, /Command/);
assert.match(markup, /cloudflared access ssh --hostname %h/);
assert.match(markup, /Use %h for the target host/);
assert.doesNotMatch(markup, /Proxy host/);
assert.doesNotMatch(markup, /Credentials/);
});
test("ProxyPanel uses a dropdown for proxy type selection", () => {
const markup = renderPanel({
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
});
assert.match(markup, /role="combobox"/);
assert.match(markup, /aria-label="Type"/);
});

View File

@@ -40,14 +40,17 @@ const installStorageStub = (viewMode: string | null = null) => {
});
};
const renderManager = (viewMode: string | null = null) => {
const renderManager = (
viewMode: string | null = null,
profiles: ProxyProfile[] = [proxyProfile],
) => {
installStorageStub(viewMode);
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ProxyProfilesManager, {
proxyProfiles: [proxyProfile],
proxyProfiles: profiles,
hosts: [],
groupConfigs: [],
onUpdateProxyProfiles: () => {},
@@ -83,3 +86,25 @@ test("ProxyProfilesManager validates proxy ports", () => {
assert.equal(isValidProxyPort(65536), false);
assert.equal(isValidProxyPort(10.5), false);
});
test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", () => {
const markup = renderManager(null, [
{
id: "proxy-command-1",
label: "Cloudflare Access",
config: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h --token secret",
},
createdAt: 1,
},
]);
assert.match(markup, /aria-label="Cloudflare Access, ProxyCommand, ProxyCommand, 0 linked"/);
assert.match(markup, /Cloudflare Access/);
assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
});

View File

@@ -1,6 +1,5 @@
import {
AlertTriangle,
Check,
ChevronDown,
Copy,
Globe,
@@ -11,12 +10,18 @@ import {
Plus,
Route,
Settings2,
SquareTerminal,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
import {
formatProxyConfigEndpoint,
isProxyCommandConfig,
isValidProxyPort,
removeProxyProfileReferences,
} from "../domain/proxyProfiles";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
@@ -47,6 +52,7 @@ import {
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "./ui/toast";
import {
VaultHeaderSearch,
@@ -100,8 +106,14 @@ const proxyProtocolMeta = {
Icon: Route,
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
},
command: {
labelKey: "hostDetails.proxyPanel.command",
Icon: SquareTerminal,
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
},
} satisfies Record<ProxyConfig["type"], {
label: string;
label?: string;
labelKey?: string;
Icon: React.ComponentType<{ size?: number; className?: string }>;
iconClassName: string;
}>;
@@ -130,8 +142,10 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
const { t } = useI18n();
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
const protocol = proxyProtocolMeta[profile.config.type];
const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label;
const ProtocolIcon = protocol.Icon;
const accessibleLabel = `${profile.label}, ${protocol.label}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
const endpoint = formatProxyConfigEndpoint(profile.config);
const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`;
return (
<ContextMenu>
@@ -154,7 +168,7 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
"h-11 w-11 rounded-xl flex items-center justify-center",
protocol.iconClassName,
)}
title={protocol.label}
title={protocolLabel}
>
<ProtocolIcon size={18} />
</div>
@@ -163,8 +177,8 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
<div className="text-sm font-semibold truncate">{profile.label}</div>
</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">
{profile.config.host}:{profile.config.port} -{" "}
{protocol.label}
{endpoint} -{" "}
{protocolLabel}
</div>
</div>
</div>
@@ -222,6 +236,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
return proxyProfiles.filter((profile) =>
profile.label.toLowerCase().includes(q) ||
profile.config.host.toLowerCase().includes(q) ||
(profile.config.command || "").toLowerCase().includes(q) ||
profile.config.type.toLowerCase().includes(q),
);
}, [proxyProfiles, search]);
@@ -269,11 +284,13 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
if (!draft) return;
const label = draft.label.trim();
const host = draft.config.host.trim();
if (!label || !host || !draft.config.port) {
const command = draft.config.command?.trim() || "";
const isCommand = isProxyCommandConfig(draft.config);
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
toast.error(t("proxyProfiles.error.required"));
return;
}
if (!isValidProxyPort(draft.config.port)) {
if (!isCommand && !isValidProxyPort(draft.config.port)) {
toast.error(t("proxyProfiles.error.port"));
return;
}
@@ -281,13 +298,20 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const saved: ProxyProfile = {
...draft,
label,
config: {
...draft.config,
host,
port: Number(draft.config.port),
username: draft.config.username?.trim() || undefined,
password: draft.config.password || undefined,
},
config: isCommand
? {
type: "command",
host: "",
port: 0,
command,
}
: {
...draft.config,
host,
port: Number(draft.config.port),
username: draft.config.username?.trim() || undefined,
password: draft.config.password || undefined,
},
updatedAt: Date.now(),
};
@@ -446,56 +470,64 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("field.type")}</p>
</div>
<div className="flex gap-2">
<Button
variant={draft.config.type === "http" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "http")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
HTTP
</Button>
<Button
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "socks5")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
SOCKS5
</Button>
</div>
<Select
value={draft.config.type}
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
>
<SelectTrigger aria-label={t("field.type")} className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Input
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
value={draft.config.host}
onChange={(event) => updateDraftConfig("host", event.target.value)}
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
className="h-10 flex-1"
/>
<Input
aria-label={t("hostDetails.port")}
type="number"
value={draft.config.port || ""}
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
placeholder="3128"
min={1}
max={65535}
step={1}
className="h-10 w-24 text-center"
/>
</div>
{isProxyCommandConfig(draft.config) ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("hostDetails.proxyPanel.commandHelp")}
</p>
<Input
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
value={draft.config.command || ""}
onChange={(event) => updateDraftConfig("command", event.target.value)}
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
className="h-10 font-mono text-xs"
/>
</div>
) : (
<div className="flex gap-2">
<Input
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
value={draft.config.host}
onChange={(event) => updateDraftConfig("host", event.target.value)}
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
className="h-10 flex-1"
/>
<Input
aria-label={t("hostDetails.port")}
type="number"
value={draft.config.port || ""}
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
placeholder="3128"
min={1}
max={65535}
step={1}
className="h-10 w-24 text-center"
/>
</div>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
@@ -518,7 +550,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
</Card>
</Card>}
</AsidePanelContent>
<AsidePanelFooter>
<Button className="w-full" onClick={saveDraft}>

View File

@@ -24,7 +24,7 @@ import {
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { CodeTextarea } from './ui/code-textarea';
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
@@ -148,8 +148,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogContent
className="max-w-md max-h-[min(90vh,720px)] flex flex-col overflow-hidden"
onKeyDown={handleKeyDown}
>
<DialogHeader className="shrink-0">
<DialogTitle>
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
</DialogTitle>
@@ -158,7 +161,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
@@ -174,18 +177,13 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<CodeTextarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px]"
/>
</div>
<SnippetScriptEditor
id="quick-add-snippet-command"
label={t('snippets.field.scriptRequired')}
value={command}
onChange={setCommand}
placeholder="echo hello"
/>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
@@ -203,7 +201,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
</div>
</div>
<DialogFooter>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>

View File

@@ -13,6 +13,7 @@ import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import { toast } from "./ui/toast";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
@@ -163,8 +164,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({
autoUpdateEnabled: settings.autoUpdateEnabled,
// Install blocked by unsaved editors in the main window — surface a toast
// here so a click from the Settings window isn't a silent no-op (#1215).
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
});
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -172,6 +178,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
notifyRendererReady();
}, [notifyRendererReady]);
useEffect(() => {
const unsubscribe = onWindowCommandCloseRequested(() => {
void closeSettingsWindow();
});
return () => unsubscribe?.();
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
useEffect(() => {
setMountedTabs((prev) => {
if (prev.has(activeTab)) return prev;
@@ -353,6 +366,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSessionLogsDir={settings.setSessionLogsDir}
sessionLogsFormat={settings.sessionLogsFormat}
setSessionLogsFormat={settings.setSessionLogsFormat}
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
toggleWindowHotkey={settings.toggleWindowHotkey}
setToggleWindowHotkey={settings.setToggleWindowHotkey}
closeToTray={settings.closeToTray}

View File

@@ -0,0 +1,162 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createDropEntriesFromClipboardFiles,
getSftpClipboardSystemTextPaths,
getSupportedClipboardUploadFiles,
isSftpNativeClipboardPasteEnabled,
resolveSftpClipboardUploadTarget,
shouldLetNativePasteEventHandleSftpPaste,
type ClipboardLocalFile,
} from "./sftp/clipboardUpload.ts";
import type { SftpFileEntry } from "../types";
const file = (name: string, overrides: Partial<SftpFileEntry> = {}): SftpFileEntry => ({
name,
type: "file",
size: 1,
modified: new Date(0),
permissions: "-rw-r--r--",
owner: "",
group: "",
...overrides,
});
test("clipboard upload targets the selected folder in the file list", () => {
const target = resolveSftpClipboardUploadTarget({
currentPath: "/home/app",
selectedFileNames: ["logs"],
files: [file("logs", { type: "directory" })],
treeSelection: [],
});
assert.equal(target, "/home/app/logs");
});
test("clipboard upload targets the current directory without a concrete folder selection", () => {
const target = resolveSftpClipboardUploadTarget({
currentPath: "/home/app",
selectedFileNames: [],
files: [file("logs", { type: "directory" })],
treeSelection: [],
});
assert.equal(target, "/home/app");
});
test("clipboard upload ignores selected regular files when resolving the target", () => {
const target = resolveSftpClipboardUploadTarget({
currentPath: "/home/app",
selectedFileNames: ["readme.md"],
files: [file("readme.md")],
treeSelection: [],
});
assert.equal(target, "/home/app");
});
test("clipboard upload targets the selected folder in the tree", () => {
const target = resolveSftpClipboardUploadTarget({
currentPath: "/home/app",
selectedFileNames: [],
files: [],
treeSelection: [{ name: "logs", path: "/var/logs", isDirectory: true }],
});
assert.equal(target, "/var/logs");
});
test("SFTP clipboard system text uses selected list paths", () => {
assert.deepEqual(
getSftpClipboardSystemTextPaths({
currentPath: "/home/app",
selectedFileNames: ["one.txt", "nested two.txt"],
treeSelection: [],
}),
["/home/app/one.txt", "/home/app/nested two.txt"],
);
});
test("SFTP clipboard system text uses selected tree paths", () => {
assert.deepEqual(
getSftpClipboardSystemTextPaths({
currentPath: "/home/app",
selectedFileNames: ["ignored.txt"],
treeSelection: [
{ name: "logs", path: "/var/logs", isDirectory: true },
{ name: "report.txt", path: "/var/report.txt", isDirectory: false },
],
}),
["/var/logs", "/var/report.txt"],
);
});
test("clipboard files become path-backed upload entries", () => {
const files: ClipboardLocalFile[] = [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
];
assert.deepEqual(createDropEntriesFromClipboardFiles(files), [
{
file: null,
localPath: "/Users/me/Desktop/report.txt",
relativePath: "report.txt",
isDirectory: false,
size: 42,
},
]);
});
test("clipboard upload ignores directories until recursive paste is supported", () => {
const files: ClipboardLocalFile[] = [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
];
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
]);
});
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + V"), true);
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "⌘ + V"), true);
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + Shift + V"), false);
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Cmd + Shift + V"), false);
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "F9"), false);
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpCopy", "Ctrl + V"), false);
});
test("native clipboard paste follows SFTP paste shortcut availability", () => {
assert.equal(
isSftpNativeClipboardPasteEnabled("disabled", [
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
]),
false,
);
assert.equal(
isSftpNativeClipboardPasteEnabled("pc", [
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Disabled", category: "sftp" },
]),
false,
);
assert.equal(
isSftpNativeClipboardPasteEnabled("pc", [
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "F9", category: "sftp" },
]),
false,
);
assert.equal(
isSftpNativeClipboardPasteEnabled("pc", [
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + Shift + V", category: "sftp" },
]),
false,
);
assert.equal(
isSftpNativeClipboardPasteEnabled("pc", [
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
]),
true,
);
});

View File

@@ -49,6 +49,8 @@ interface SftpSidePanelProps {
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
/** The terminal session id whose SSH connection can be reused for SFTP */
activeSessionId?: string | null;
initialLocation?: { hostId: string; path: string } | null;
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
showWorkspaceHostHeader?: boolean;
@@ -83,6 +85,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
updateHosts,
sftpDefaultViewMode,
activeHost,
activeSessionId,
initialLocation,
onInitialLocationApplied,
showWorkspaceHostHeader = false,
@@ -446,12 +449,13 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
s.connect("left", activeHost, {
sourceSessionId: activeSessionId ?? undefined,
...(needsNewTab ? { forceNewTab: true } : undefined),
onTabCreated: (tabId) => {
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
}, [activeHost, activeSessionId, hasActiveWork]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.

View File

@@ -7,7 +7,7 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-pane
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Input } from './ui/input';
import { CodeTextarea } from './ui/code-textarea';
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Combobox } from './ui/combobox';
import { DistroAvatar } from './DistroAvatar';
@@ -165,12 +165,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
{/* Script */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.scriptRequired')}</p>
<CodeTextarea
<SnippetScriptEditor
label={t('snippets.field.scriptRequired')}
placeholder="ls -l"
className="min-h-[120px]"
value={editingSnippet.command || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.variablesHelp')}

View File

@@ -73,6 +73,8 @@ import {
forceSyncRenderAfterResize,
formatNetSpeed,
MAX_CONNECTION_LOG_DATA_CHARS,
shouldHideConnectingDialogForConnectionReuse,
shouldShowTerminalConnectionDialog,
type TerminalProps,
} from "./terminal/terminalHelpers";
@@ -99,6 +101,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
startupCommand,
noAutoRun,
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
keyBindings = [],
@@ -126,6 +129,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onBroadcastInput,
onSnippetExecutorChange,
sessionLog,
sshDebugLogEnabled,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -229,6 +233,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
@@ -425,6 +430,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
starters.startMosh(term);
return;
}
if (host.etEnabled) {
starters.startEt(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
@@ -568,6 +577,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
knownHosts,
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionId,
startupCommand,
noAutoRun,
terminalSettings,
@@ -605,7 +615,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
const isTelnet = host.protocol === 'telnet';
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
const isEt = host.protocol === 'et' || host.etEnabled;
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
return;
@@ -629,9 +640,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOsDetected,
onCommandExecuted,
sessionLog,
sshDebugLogEnabled,
});
sessionStartersRef.current = sessionStarters;
useEffect(() => {
setConnectionReuseFellBack(false);
if (!reuseConnectionFromSessionId) return undefined;
return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => {
if (fallbackSessionId === sessionId) {
setConnectionReuseFellBack(true);
}
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
@@ -878,6 +901,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
@@ -893,6 +917,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else if (host.etEnabled) {
sessionStarters.startEt(term);
} else {
sessionStarters.startSSH(term);
}
@@ -929,9 +955,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
};
const shouldShowConnectionDialog = status !== "connected"
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
isLocalConnection,
isSerialConnection,
isDisconnectedDialogDismissed,
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId,
host,
connectionReuseFellBack,
}),
});
const {
handleDragEnter,

View File

@@ -29,6 +29,7 @@ const baseProps = {
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
editorWordWrap: false,
sshDebugLogsEnabled: false,
setEditorWordWrap: () => {},
onHotkeyAction: () => {},
onUpdateHost: () => {},
@@ -118,3 +119,13 @@ test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
false,
);
});
test("TerminalLayer re-renders when SSH debug logging changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, sshDebugLogsEnabled: true } as never,
),
false,
);
});

View File

@@ -1,6 +1,7 @@
import { FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import {
getSessionActivityIdsToClear,
@@ -37,6 +38,7 @@ import { Button } from './ui/button';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
import { useTerminalLayerEffects } from './terminalLayer/useTerminalLayerEffects';
import { TerminalLayerView } from './terminalLayer/TerminalLayerView';
@@ -118,6 +120,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sshDebugLogsEnabled,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
}) => {
@@ -170,6 +173,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
@@ -518,11 +522,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
map.set(session.id, existingHost);
} else {
@@ -531,6 +537,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol,
port,
moshEnabled,
etEnabled,
});
}
} else {
@@ -555,6 +562,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tags: [],
protocol: fallbackProtocol,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
@@ -657,6 +665,21 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return sftpHostForTab.get(activeTabId) ?? null;
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
const activeTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab || !sftpActiveHost) return null;
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
if (!sessionId) return null;
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return null;
const sessionHost = sessionHostsMap.get(session.id);
if (!sessionHost) return null;
const sameEndpoint =
sessionHost.hostname === sftpActiveHost.hostname &&
(sessionHost.port || 22) === (sftpActiveHost.port || 22) &&
(sessionHost.username || "root") === (sftpActiveHost.username || "root");
return sameEndpoint ? session.id : null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
@@ -840,11 +863,32 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSwitchSidePanelTab('theme');
}, [handleSwitchSidePanelTab]);
// Open AI chat side panel
// Open AI chat side panel (side-panel rail button: a plain switch that is a
// no-op when AI is already the active sub-panel, matching the other rail tabs)
const handleOpenAI = useCallback(() => {
handleSwitchSidePanelTab('ai');
}, [handleSwitchSidePanelTab]);
// Toggle the AI chat side panel from the top-bar button: open it (or switch
// to it from another sub-panel), and close the side panel when AI is already
// the open sub-panel. Unlike handleOpenAI (the rail switch), a second click
// here dismisses the panel.
const handleToggleAiFromTopBar = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const intent = resolveAiSidePanelToggleIntent(
sidePanelOpenTabsRef.current.get(tabId) ?? null,
);
if (intent.kind === 'closeTerminalSidePanel') {
handleCloseSidePanel();
return;
}
handleSwitchSidePanelTab('ai');
}, [handleCloseSidePanel, handleSwitchSidePanelTab]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
@@ -980,8 +1024,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Track previous focusedSessionId to detect changes
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleOpenAI, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeTerminalSessionIdForSftp, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, sshDebugLogsEnabled, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);

View File

@@ -1,4 +1,4 @@
import { Bell, Folder, FolderLock, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
import { Folder, FolderLock, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId } from '../application/state/activeTabStore';
import type { EditorTab } from '../application/state/editorTabStore';
@@ -604,9 +604,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -430,9 +430,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (protocolSelectHost) {
const hostWithProtocol: Host = {
...protocolSelectHost,
protocol: protocol === "mosh" ? "ssh" : protocol,
protocol: (protocol === "mosh" || protocol === "et") ? "ssh" : protocol,
port,
moshEnabled: protocol === "mosh",
etEnabled: protocol === "et",
};
onConnect(hostWithProtocol);
setProtocolSelectHost(null);

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { extractDisplayCommand } from './tool-call';
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
// outer quote leaks (the "netcatty: \"" / "netcatty: …md\"" garbage titles).
test('unwraps a /bin/zsh -lc string wrapper (codex SDK shape)', () => {
assert.equal(
extractDisplayCommand({ command: `/bin/zsh -lc 'echo "hi"'` }),
'echo "hi"',
);
});
test('codex Skills+CLI exec: unwrap shell + netcatty-cli -> remote command', () => {
assert.equal(
extractDisplayCommand({
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
}),
'uptime',
);
});
test('codex Skills+CLI session subcommand -> friendly title', () => {
assert.equal(
extractDisplayCommand({
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" session --session X'`,
}),
'netcatty: inspect session',
);
});
test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
assert.equal(
extractDisplayCommand({ command: `"/abs/netcatty-tool-cli" exec --session X -- "uptime"` }),
'uptime',
);
});
test('netcatty-tool-cli env -> list sessions', () => {
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
});
test('array shell-wrap shape still unwraps (regression)', () => {
assert.equal(
extractDisplayCommand({ command: ['zsh', '-lc', 'ls -la /tmp'] }),
'ls -la /tmp',
);
});
test('plain command passes through unchanged', () => {
assert.equal(extractDisplayCommand({ command: 'ls -la /tmp' }), 'ls -la /tmp');
});
test('empty / missing args -> null', () => {
assert.equal(extractDisplayCommand(undefined), null);
assert.equal(extractDisplayCommand({ command: '' }), null);
});

View File

@@ -12,8 +12,13 @@ import { useI18n } from '../../application/i18n/I18nProvider';
*
* Different tool surfaces hand us different shapes:
* - Netcatty's own `terminal_execute` MCP tool → `{command: "<string>"}`
* - Codex `local_shell` (ACP) → `{command: ["zsh","-lc","<full>"]}`
* - Claude `Bash` (ACP) → `{command: "<string>"}`
* - Codex `local_shell` → `{command: ["zsh","-lc","<full>"]}`
* - Codex command_execution (SDK) → `{command: "/bin/zsh -lc '<full>'"}`
* - Claude `Bash` → `{command: "<string>"}`
*
* The SDK form is a STRING that wraps the real command in `<shell> -lc '<full>'`,
* so we unwrap that wrapper too (the array branch already did the equivalent) —
* otherwise the outer shell quotes leak into the title.
*
* And under the "Skill + CLI" integration, the agent's shell tool wraps a
* call to our internal `netcatty-tool-cli` binary, so the real intent is one
@@ -25,7 +30,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
* cares about (the remote command), not Codex's wrapper title which is
* just the local path to the CLI binary.
*/
function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
export function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
if (!args) return null;
const raw = (args as { command?: unknown }).command;
@@ -45,6 +50,15 @@ function extractDisplayCommand(args: Record<string, unknown> | undefined): strin
return null;
}
// Unwrap a STRING shell wrapper, e.g. Codex SDK's `/bin/zsh -lc '<full>'`.
// The array branch above already extracts the inner command; the string form
// (codex command_execution) does not, so strip `<shell> -l?c <quote>…<quote>`
// here. Without this the outer quote leaks into the netcatty-cli title below.
const strWrap = cmdString.match(
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
);
if (strWrap) cmdString = strWrap[2];
// Netcatty CLI wrapper extraction.
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
if (cliIdx >= 0) {

View File

@@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
getExternalAgentSdkBackend,
isSettingsManagedDiscoveredAgent,
matchesManagedAgentConfig,
} from '../../infrastructure/ai/managedAgents';
@@ -131,7 +132,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
const enabledExternalAgents = useMemo(
() =>
externalAgents
.filter((agent) => agent.enabled)
.filter((agent) => agent.enabled && Boolean(getExternalAgentSdkBackend(agent)))
.map(
(agent): AgentInfo => ({
id: agent.id,

View File

@@ -92,7 +92,7 @@ interface ChatInputProps {
/**
* Provider→model two-level picker payload. When provided, replaces the
* single-list model dropdown with a provider-aware picker. Used for the
* Catty Agent only — external ACP agents (Claude/Codex) keep the
* Catty Agent only — external SDK agents (Claude/Codex) keep the
* `modelPresets` dropdown because their provider is wired inside the CLI.
*/
providerSwitcher?: ProviderSwitcherConfig;
@@ -390,7 +390,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const selectedBaseModelId = selectedPreset?.id;
// Provider switcher mode (Catty Agent): two-column popover, chip carries
// the provider's icon + name + model name. Falls back to the existing
// single-list model dropdown for ACP agents.
// single-list model dropdown for external SDK agents.
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
// Resolve to the actually-bound provider only — no `?? providers[0]`
// fallback, since a provider that isn't really bound will still hit the

View File

@@ -323,7 +323,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.map(([id, req]) => {

View File

@@ -9,14 +9,22 @@ const agents: ExternalAgentConfig[] = [
id: 'enabled-agent',
name: 'Enabled Agent',
command: '/usr/local/bin/enabled-agent',
sdkBackend: 'codex',
enabled: true,
},
{
id: 'disabled-agent',
name: 'Disabled Agent',
command: '/usr/local/bin/disabled-agent',
sdkBackend: 'codex',
enabled: false,
},
{
id: 'missing-backend-agent',
name: 'Missing Backend Agent',
command: '/usr/local/bin/missing-backend-agent',
enabled: true,
},
];
test('canSendWithAgent allows Catty and enabled external agents', () => {
@@ -26,6 +34,7 @@ test('canSendWithAgent allows Catty and enabled external agents', () => {
test('canSendWithAgent blocks missing or disabled external agents', () => {
assert.equal(canSendWithAgent('disabled-agent', agents), false);
assert.equal(canSendWithAgent('missing-backend-agent', agents), false);
assert.equal(canSendWithAgent('missing-agent', agents), false);
});

View File

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

View File

@@ -15,11 +15,12 @@ test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", ()
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(result.configDir, "/cfg");
assert.equal(result.settingsPath, "");
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
});
test("splitClaudeEnv handles undefined env", () => {
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", settingsPath: "", envText: "" });
});
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
@@ -35,7 +36,7 @@ test("serializeEnvLines is the inverse for simple entries", () => {
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
const next = buildClaudeEnv(prev, "/cfg", "", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
@@ -44,14 +45,15 @@ test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECU
});
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
assert.equal(buildClaudeEnv(undefined, " ", " ", ""), undefined);
});
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
const next = buildClaudeEnv(
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
"/cfg",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
"",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nNETCATTY_CLAUDE_SETTINGS=/evil/settings.json\nANTHROPIC_API_KEY=sk-x",
);
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
@@ -59,3 +61,28 @@ test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
ANTHROPIC_API_KEY: "sk-x",
});
});
test("splitClaudeEnv + buildClaudeEnv round-trip the settings marker (NETCATTY_CLAUDE_SETTINGS)", () => {
const split = splitClaudeEnv({
CLAUDE_CONFIG_DIR: "/cfg",
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(split.settingsPath, "/team/settings.json");
assert.equal(split.configDir, "/cfg");
// the marker is kept out of the free-text env editor
assert.equal(split.envText, "ANTHROPIC_API_KEY=sk-x");
// config dir + settings coexist (settings is additive, not a replacement for CLAUDE_CONFIG_DIR)
const rebuilt = buildClaudeEnv(undefined, "/cfg", "/team/settings.json", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(rebuilt, {
CLAUDE_CONFIG_DIR: "/cfg",
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
ANTHROPIC_API_KEY: "sk-x",
});
// settings alone (no config dir) is allowed
assert.deepEqual(buildClaudeEnv(undefined, "", "/only/settings.json", ""), {
NETCATTY_CLAUDE_SETTINGS: "/only/settings.json",
});
});

View File

@@ -3,9 +3,9 @@ import test from "node:test";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import {
buildAcpHistoryMessages,
buildAcpHistoryMessagesForBridge,
} from "./acpHistory.ts";
buildExternalAgentHistoryMessages,
buildExternalAgentHistoryMessagesForBridge,
} from "./externalAgentHistory.ts";
function message(
id: string,
@@ -22,14 +22,14 @@ function message(
};
}
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
test("buildExternalAgentHistoryMessages compacts older external agent context and keeps only recent raw turns", () => {
const messages: ChatMessage[] = [
message("u1", "user", "我希望最小改动,不要添加很多 test"),
message("a1", "assistant", "已按最小改动处理"),
message("u2", "user", "MCP 不允许使用Windows 上不要假设 pwsh.exe"),
message("a2", "assistant", "PR #738 已创建commit 4181a2c"),
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
message("a3", "assistant", "建议 ACP history compaction"),
message("a3", "assistant", "建议 SDK agent history compaction"),
message("tool1", "tool", "", {
toolResults: [
{
@@ -47,7 +47,7 @@ test("buildAcpHistoryMessages compacts older ACP context and keeps only recent r
message("a6", "assistant", "还没提交"),
];
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Compact prior Netcatty UI context/);
@@ -64,17 +64,17 @@ test("buildAcpHistoryMessages compacts older ACP context and keeps only recent r
assert.ok(result.every((entry) => entry.content.length <= 3000));
});
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
test("buildExternalAgentHistoryMessagesForBridge keeps fallback history available for stale SDK agent session recovery", () => {
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
assert.equal(buildExternalAgentHistoryMessagesForBridge([], "sdk-session-1"), undefined);
assert.deepEqual(
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
buildAcpHistoryMessages(messages),
buildExternalAgentHistoryMessagesForBridge(messages, "sdk-session-1"),
buildExternalAgentHistoryMessages(messages),
);
});
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
message("a1", "assistant", "Understood."),
@@ -87,7 +87,7 @@ test("buildAcpHistoryMessages preserves older substantive user instructions outs
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
@@ -104,7 +104,7 @@ test("buildAcpHistoryMessages preserves older substantive user instructions outs
);
});
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
test("buildExternalAgentHistoryMessages preserves short important user constraints outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "不要提交"),
message("a1", "assistant", "收到"),
@@ -117,13 +117,13 @@ test("buildAcpHistoryMessages preserves short important user constraints outside
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
test("buildExternalAgentHistoryMessages does not treat pr inside ordinary words as important", () => {
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
// Those words land at priority=1 (kept only as space allows) while the
@@ -152,13 +152,13 @@ test("buildAcpHistoryMessages does not treat pr inside ordinary words as importa
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
test("buildExternalAgentHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
const messages: ChatMessage[] = [];
for (let index = 1; index <= 12; index += 1) {
@@ -188,13 +188,13 @@ test("buildAcpHistoryMessages prioritizes later durable instructions over older
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
});
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
test("buildExternalAgentHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Please propose a migration plan for the sidebar state."),
message(
@@ -213,13 +213,13 @@ test("buildAcpHistoryMessages preserves older substantive assistant context that
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move the derived view state into that hook\./);
});
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
test("buildExternalAgentHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
// would previously be dropped by a blanket length<10 heuristic, even
// though they don't match any TRIVIAL pattern.
@@ -239,14 +239,14 @@ test("buildAcpHistoryMessages preserves short non-trivial user constraints that
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Use ssh2/);
assert.match(result[0].content, /中文输出/);
});
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
test("buildExternalAgentHistoryMessages still drops one-word filler user messages", () => {
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
// "thanks" filler to leak into the compact section.
const messages: ChatMessage[] = [
@@ -263,7 +263,7 @@ test("buildAcpHistoryMessages still drops one-word filler user messages", () =>
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
// u1 / u2 fall outside the recent raw window. The compact context, if it
// exists, must not surface these trivial turns as durable user requests.
@@ -273,7 +273,7 @@ test("buildAcpHistoryMessages still drops one-word filler user messages", () =>
}
});
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
// Regression: tool results used to only reach fallback replay via the
// 500-char compact summary. If the user's last interaction produced a
// large tool output (cat/rg/fetched file), any "use that output"-style
@@ -293,7 +293,7 @@ test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the
message("u2", "user", "use that output"),
];
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Raw-window tool result carries both the [from ...] provenance label
@@ -309,7 +309,7 @@ test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the
);
});
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
test("buildExternalAgentHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
// Regression: if the raw window starts mid-tool-interaction, the
// preceding assistant tool_call message may be outside the 6-item
// slice. Without the call's name/args inline on the result line, the
@@ -334,7 +334,7 @@ test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is inte
message("u3", "user", "now do the same for /etc/resolv.conf"),
];
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// The tool_result line must carry the originating tool_call's name and
@@ -344,7 +344,7 @@ test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is inte
assert.match(flat, /cat \/etc\/hosts/);
});
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
test("buildExternalAgentHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
// Regression target: codex review flagged that the compaction path
// scanned messages.entries() over the full transcript. Build a very
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
@@ -373,7 +373,7 @@ test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) wo
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Recent priority-2 constraint is kept.
@@ -382,7 +382,7 @@ test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) wo
assert.doesNotMatch(flat, /old-marker-xyz/);
});
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
test("buildExternalAgentHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
// the raw message array. In a tool-heavy chat, each user turn can
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
@@ -423,7 +423,7 @@ test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat
// Sanity: the message count is over 200 even though user turns are 30.
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Under the old raw-count bound, the early constraint would age out;
@@ -431,7 +431,7 @@ test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
});
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
test("buildExternalAgentHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
// Regression: isSubstantiveAssistantMessage previously required length
// >= 40 OR a small English keyword match OR a numbered list. Short
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
@@ -456,7 +456,7 @@ test("buildAcpHistoryMessages preserves short non-trivial assistant decisions th
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.match(flat, /Use ssh2/);
@@ -464,7 +464,7 @@ test("buildAcpHistoryMessages preserves short non-trivial assistant decisions th
assert.match(flat, /rebase instead/);
});
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
test("buildExternalAgentHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
// Sanity: removing the length/keyword gate must not let assistant
// filler leak into the compact durable-assistant section.
const messages: ChatMessage[] = [
@@ -483,7 +483,7 @@ test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' /
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.doesNotMatch(flat, /Assistant context: ack\b/);
@@ -491,7 +491,7 @@ test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' /
assert.doesNotMatch(flat, /Assistant context: 明白/);
});
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
test("buildExternalAgentHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
// Regression: the raw-window fix covered the last 6 items, but once
// a tool result fell into the compact section (summarizeToolMessage
// path) the `[from <name>(<args>)]` provenance label was absent.
@@ -530,7 +530,7 @@ test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Both older tool results must now carry provenance labels so a
@@ -539,7 +539,7 @@ test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
});
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
test("buildExternalAgentHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
// Without skipping raw-window items, the same last-6 turns would be
// summarized in the compact section AND appended verbatim in the raw
@@ -570,7 +570,7 @@ test("buildAcpHistoryMessages does not duplicate recent raw turns into the compa
message("u-rec2", "user", "now push"),
);
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
assert.ok(compact, "expected a compact context message");
@@ -587,7 +587,7 @@ test("buildAcpHistoryMessages does not duplicate recent raw turns into the compa
assert.match(rawFlat, /RAW_TOOL_MARKER/);
});
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
// assistant tool_call with the same id overwrite the older one. An
// older tool_result in the replay history would then be annotated
@@ -622,7 +622,7 @@ test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Each tool_result must be annotated with ITS OWN preceding call's
@@ -638,7 +638,7 @@ test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
});
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
test("buildExternalAgentHistoryMessages preserves assistant-only compact context", () => {
const messages: ChatMessage[] = [
message("u1", "user", "ok"),
message(
@@ -655,7 +655,7 @@ test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
);
}
const result = buildAcpHistoryMessages(messages);
const result = buildExternalAgentHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);

View File

@@ -1,7 +1,7 @@
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
type DurableUserLine = {
line: string;
messageIndex: number;
@@ -10,7 +10,7 @@ type DurableUserLine = {
const MAX_RECENT_RAW_MESSAGES = 6;
const MAX_MESSAGES_TO_SCAN = 20;
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
// Bound the scan by user turns, not raw message count: a tool-heavy external agent
// chat can produce 5+ messages per logical turn (user + assistant +
// several tool_results + follow-up assistant), so a plain
// message-count cap ages out early constraints much sooner than intended.
@@ -238,7 +238,7 @@ function toRawHistoryMessage(
// per message, ~2000). Without this, follow-up turns after stale-session
// recovery would only see the 500-char compact summary in
// summarizeToolMessage, losing the actual bytes the user might reference
// ("use that output", "what did cat show?"). ACP only supports user/
// ("use that output", "what did cat show?"). external agent replay only supports user/
// assistant roles, so we flatten to "assistant" — the tool results were
// produced during the assistant's turn.
//
@@ -270,7 +270,7 @@ function buildCompactContext(
durableScanStart: number,
recentRawSourceIds: Set<string>,
toolCallIndex: Map<string, ToolCallInfo>,
): AcpHistoryMessage[] {
): ExternalAgentHistoryMessage[] {
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
const summaryLines: string[] = [];
const durableUserCandidates: DurableUserLine[] = [];
@@ -354,7 +354,7 @@ function buildCompactContext(
const contentLines = [
"[Compact prior Netcatty UI context]",
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
"The external SDK agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
];
if (durableUserLines.length) {
contentLines.push("Earlier user requests that may still apply:");
@@ -395,7 +395,7 @@ function computeDurableScanStart(messages: ChatMessage[]): number {
return 0;
}
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
export function buildExternalAgentHistoryMessages(messages: ChatMessage[]): ExternalAgentHistoryMessage[] {
// Compute the scan start once, then do all subsequent work over the
// already-sliced tail. This avoids O(N) walks over the whole transcript
// on every send — previously buildToolCallIndex + the flatMap-to-take-
@@ -427,12 +427,12 @@ export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMess
return [...compactContext, ...recentRaw];
}
export function buildAcpHistoryMessagesForBridge(
export function buildExternalAgentHistoryMessagesForBridge(
messages: ChatMessage[],
_existingSessionId?: string | null,
): AcpHistoryMessage[] | undefined {
): ExternalAgentHistoryMessage[] | undefined {
// The main process bridge only consumes this payload during stale-session
// fallback replay, so keep it available even when a session id exists.
const historyMessages = buildAcpHistoryMessages(messages);
const historyMessages = buildExternalAgentHistoryMessages(messages);
return historyMessages.length ? historyMessages : undefined;
}

View File

@@ -94,15 +94,15 @@ 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[],
aiSdkAgentListModels?: (
sdkBackend: string,
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiSdkAgentCancel?: (requestId: string, chatSessionId?: string) => Promise<{ ok: boolean; error?: string }>;
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{

View File

@@ -3,7 +3,7 @@
*
* Handles:
* - Catty agent streaming via Vercel AI SDK `streamText`
* - External agent streaming (ACP and raw process)
* - External agent streaming through official SDK backends
* - Text-delta batching via requestAnimationFrame
* - Abort controller management
* - Stream state tracking (per-session)
@@ -11,7 +11,7 @@
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import { generateText, pruneMessages, streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AIToolIntegrationMode,
@@ -25,11 +25,21 @@ import type {
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import {
CONTEXT_COMPACTION_SYSTEM_PROMPT,
DEFAULT_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROTECT_RECENT_MESSAGES,
formatMessagesForCompaction,
estimateUnknownTokens,
keepRecentContextMessages,
prepareContextCompaction,
resolveContextWindow,
} from '../../../infrastructure/ai/contextCompaction';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAgents';
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
import {
@@ -39,6 +49,7 @@ import {
mergeProviderContinuation,
normalizeProviderContinuationOptions,
withProviderContinuationSource,
type OpenAIChatAssistantFields,
type ProviderContinuation,
} from '../../../infrastructure/ai/providerContinuation';
@@ -67,6 +78,11 @@ export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
type ModelMessageWithOpenAIChatFields = ModelMessage & {
[OPENAI_CHAT_ASSISTANT_FIELDS]?: OpenAIChatAssistantFields;
};
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
@@ -78,6 +94,45 @@ function emitStreamingStoreChange(): void {
});
}
function rememberOpenAIChatAssistantFields(
message: ModelMessage,
fields: OpenAIChatAssistantFields | undefined,
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): void {
fieldsByMessage.set(message, fields);
(message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS] = fields;
}
function getRememberedOpenAIChatAssistantFields(
message: ModelMessage,
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): OpenAIChatAssistantFields | undefined {
if (fieldsByMessage.has(message)) return fieldsByMessage.get(message);
return (message as ModelMessageWithOpenAIChatFields)[OPENAI_CHAT_ASSISTANT_FIELDS];
}
function modelMessageHasToolCall(message: ModelMessage): boolean {
if (message.role !== 'assistant' || !Array.isArray(message.content)) return false;
return message.content.some((part) => part && typeof part === 'object' && (part as { type?: string }).type === 'tool-call');
}
function collectOpenAIChatAssistantFieldsForMessages(
messages: ModelMessage[],
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>,
): Array<OpenAIChatAssistantFields | undefined> {
const fields: Array<OpenAIChatAssistantFields | undefined> = [];
let previousMessageWasTool = false;
for (const message of messages) {
const needsContinuationFields = message.role === 'assistant'
&& (modelMessageHasToolCall(message) || previousMessageWasTool);
if (needsContinuationFields) {
fields.push(getRememberedOpenAIChatAssistantFields(message, fieldsByMessage));
}
previousMessageWasTool = message.role === 'tool';
}
return fields;
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
@@ -123,13 +178,13 @@ export interface UseAIChatStreamingReturn {
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
/** Send a message to an external SDK agent. */
sendToExternalAgent: (
sessionId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => Promise<void>;
/** Report a streaming error to the chat. */
@@ -522,7 +577,7 @@ export function useAIChatStreaming({
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
@@ -532,8 +587,9 @@ export function useAIChatStreaming({
context.selectedUserSkillSlugs,
);
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const sdkBackend = getExternalAgentSdkBackend(agentConfig);
if (sdkBackend && bridge) {
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
// Push terminal session metadata to MCP bridge
if (bridge?.aiMcpUpdateSessions) {
@@ -552,7 +608,7 @@ export function useAIChatStreaming({
}
};
await runAcpAgentTurn(
await runSdkAgentTurn(
bridge,
requestId,
sessionId,
@@ -594,7 +650,7 @@ export function useAIChatStreaming({
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
// Only patch tool call name if the existing name is missing/generic
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
const updatedToolCalls = toolName && !toolName.includes('sdk_agent_dynamic_tool') && msg.toolCalls
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
: msg.toolCalls;
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
@@ -621,7 +677,7 @@ export function useAIChatStreaming({
onDone: () => {},
},
abortController.signal,
// Managed ACP agents (codex, claude) must resolve auth from their own
// Managed SDK agents (codex, claude) must resolve auth from their own
// CLI config/login state, so we deliberately pass no providerId here.
// See issue #705 for Codex; same reasoning for Claude.
undefined,
@@ -634,23 +690,13 @@ export function useAIChatStreaming({
userSkillsContext,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
},
onDone: () => {},
},
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
// Managed agents always route through the SDK path above.
reportStreamError(
sessionId,
abortController.signal,
'This agent has no SDK backend configured. Re-discover it in Settings -> AI.',
);
setStreamingForScope(sessionId, false);
}
}, [
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
@@ -747,6 +793,7 @@ export function useAIChatStreaming({
};
const sdkMessages: Array<ModelMessage> = [];
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
@@ -811,9 +858,10 @@ export function useAIChatStreaming({
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
sdkMessages.push(message);
if (resolvedCalls.length > 0) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
}
}
} else if (m.content) {
@@ -831,12 +879,13 @@ export function useAIChatStreaming({
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
sdkMessages.push({
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
});
};
sdkMessages.push(message);
if (currentMessageFollowsToolResult) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
@@ -888,12 +937,64 @@ export function useAIChatStreaming({
return;
}
const contextWindow = resolveContextWindow({
provider: context.activeProvider,
modelId: activeModelId,
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
});
const outputReserveTokens = Math.min(4096, Math.ceil(contextWindow * 0.05));
const requestReserveTokens = outputReserveTokens + estimateUnknownTokens({
systemPrompt,
toolNames: Object.keys(tools),
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
});
let messagesForStream = sdkMessages;
try {
const compacted = await prepareContextCompaction({
messages: sdkMessages,
contextWindow,
reservedTokens: requestReserveTokens,
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
summarize: async (messagesToSummarize) => {
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
const result = await generateText({
model,
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
}],
abortSignal: abortController.signal,
maxOutputTokens: 1600,
temperature: 0,
});
return result.text;
},
});
messagesForStream = compacted.messages;
} catch (err) {
if (abortController.signal.aborted) throw err;
console.warn('[Catty] Context compaction failed; falling back to recent messages only:', err);
messagesForStream = keepRecentContextMessages(sdkMessages, DEFAULT_PROTECT_RECENT_MESSAGES);
}
messagesForStream = pruneMessages({
messages: messagesForStream,
reasoning: 'all',
emptyMessages: 'remove',
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
messagesForStream,
openAIChatAssistantFieldsByMessage,
);
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
sdkMessages,
messagesForStream,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,

View File

@@ -11,8 +11,7 @@ test('buildManagedAgentState removes stale managed agents when path detection fa
name: 'Codex CLI',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
sdkBackend: 'codex',
},
{
id: 'custom-agent',
@@ -43,8 +42,7 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
name: 'Claude Code',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
sdkBackend: 'claude',
},
{
id: 'custom-agent',
@@ -68,7 +66,7 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
test('buildManagedAgentState stores the system Claude executable for SDK runs', () => {
const state = buildManagedAgentState(
[],
'catty',
@@ -78,11 +76,31 @@ test('buildManagedAgentState stores the system Claude executable for ACP runs',
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
assert.equal(state.agents[0].sdkBackend, 'claude');
assert.deepEqual(state.agents[0].env, {
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
});
});
test('buildManagedAgentState stores SDK backend keys for discovered managed agents', () => {
const codexState = buildManagedAgentState(
[],
'catty',
'codex',
{ path: '/opt/homebrew/bin/codex', version: '1.0.0', available: true },
);
const copilotState = buildManagedAgentState(
[],
'catty',
'copilot',
{ path: '/opt/homebrew/bin/copilot', version: '1.0.0', available: true },
);
assert.equal(codexState.agents[0].sdkBackend, 'codex');
assert.equal(copilotState.agents[0].sdkBackend, 'copilot');
assert.equal(copilotState.agents[0].acpArgs, undefined);
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{
@@ -90,8 +108,7 @@ test('buildManagedAgentState does not remove user-created matching agents', () =
name: 'My Claude Wrapper',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
sdkBackend: 'claude',
},
];
@@ -113,8 +130,7 @@ test('buildManagedAgentState only rewrites settings-managed discovered agents',
name: 'My Codex Wrapper',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
sdkBackend: 'codex',
},
];
@@ -122,7 +138,7 @@ test('buildManagedAgentState only rewrites settings-managed discovered agents',
agents,
'my-codex-wrapper',
'codex',
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
{ path: '/opt/netcatty/codex', version: 'Bundled legacy adapter', available: true },
);
assert.deepEqual(

View File

@@ -2,7 +2,7 @@ import React, { type Dispatch, type RefObject, type SetStateAction } from 'react
import { Database, Github, History, Server, Trash2 } from 'lucide-react';
import type { CloudProvider, SyncPayload } from '../../domain/sync';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { isProviderReadyForSync } from '../../domain/sync';
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
@@ -116,7 +116,11 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
error={
sync.providers.onedrive.error
? cleanOneDriveErrorMessage(sync.providers.onedrive.error)
: undefined
}
disabled={isConnectDisabled('onedrive')}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}

View File

@@ -1,12 +1,11 @@
/**
* Proxy Configuration Sub-Panel
* Panel for configuring HTTP/SOCKS5 proxy settings
* Panel for configuring HTTP/SOCKS5/ProxyCommand proxy settings
*/
import { Check, Globe, KeyRound, Trash2 } from 'lucide-react';
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isValidProxyPort } from '../../domain/proxyProfiles';
import { cn } from '../../lib/utils';
import { formatProxyConfigEndpoint, formatProxyConfigType, isProxyCommandConfig, isValidProxyPort } from '../../domain/proxyProfiles';
import { ProxyConfig, ProxyProfile } from '../../types';
import { AsidePanel, AsidePanelContent, type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
@@ -47,9 +46,12 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
const isUsingProfile = Boolean(selectedProfile);
const isCommandProxy = isProxyCommandConfig(proxyConfig);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasInvalidManualProxyPort = hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const canSave = isUsingProfile || (hasManualProxyHost && !hasInvalidManualProxyPort);
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const canSave = isUsingProfile || (hasManualProxyValue && !hasInvalidManualProxyPort);
const handleBack = useCallback(() => {
if (hasInvalidManualProxyPort) return;
onBack();
@@ -104,10 +106,10 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{selectedProfile.config.type.toUpperCase()}
{formatProxyConfigType(selectedProfile.config)}
</Badge>
<span className="truncate">
{selectedProfile.config.host}:{selectedProfile.config.port}
{formatProxyConfigEndpoint(selectedProfile.config)}
</span>
</div>
</div>
@@ -118,56 +120,65 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
{!isUsingProfile && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('field.type')}</p>
</div>
<div className="flex gap-2">
<Button
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'http')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
HTTP
</Button>
<Button
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'socks5')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
SOCKS5
</Button>
</div>
<Select
value={proxyConfig?.type || 'http'}
onValueChange={(value) => onUpdateProxy('type', value as ProxyConfig['type'])}
>
<SelectTrigger aria-label={t('field.type')} className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="command">{t('hostDetails.proxyPanel.command')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Input
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
{isCommandProxy ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<SquareTerminal size={14} />
<span>{t('hostDetails.proxyPanel.commandHelp')}</span>
</div>
<Input
aria-label={t('hostDetails.port')}
type="number"
placeholder="3128"
min={1}
max={65535}
step={1}
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
aria-label={t('hostDetails.proxyPanel.commandPlaceholder')}
placeholder={t('hostDetails.proxyPanel.commandPlaceholder')}
value={proxyConfig?.command || ""}
onChange={(e) => onUpdateProxy('command', e.target.value)}
className="h-10 font-mono text-xs"
/>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
<Input
aria-label={t('hostDetails.port')}
type="number"
placeholder="3128"
min={1}
max={65535}
step={1}
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
/>
</div>
</div>
)}
{hasInvalidManualProxyPort && (
<p className="text-xs text-destructive">
{t('proxyProfiles.error.port')}
@@ -175,7 +186,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
{!isCommandProxy && <Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
@@ -198,11 +209,11 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
</Card>
</Card>}
</>
)}
{(proxyConfig?.host || selectedProxyProfileId) && (
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>

View File

@@ -2,12 +2,13 @@
* View Key Panel - Display SSH key details
*/
import { Copy,Info } from 'lucide-react';
import React from 'react';
import { Check, Copy, Info } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { copyToClipboard } from './utils';
interface ViewKeyPanelProps {
@@ -20,6 +21,15 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
onExport,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopyPublicKey = useCallback(async () => {
const ok = await copyToClipboard(keyItem.publicKey || '');
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [keyItem.publicKey]);
return (
<>
<div className="space-y-2">
@@ -30,18 +40,34 @@ export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
{keyItem.publicKey && (
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<div className="relative">
<div className="p-3 bg-card border border-border/80 rounded-lg font-mono text-xs break-all max-h-32 overflow-y-auto">
<div className="flex rounded-lg border border-border/80 bg-card overflow-hidden">
<div className="flex-1 min-w-0 p-3 font-mono text-xs break-all max-h-32 overflow-y-auto">
{keyItem.publicKey}
</div>
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2 h-7 w-7"
onClick={() => copyToClipboard(keyItem.publicKey || '')}
>
<Copy size={12} />
</Button>
<div className="shrink-0 flex flex-col border-l border-border/60 p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => void handleCopyPublicKey()}
aria-label={
copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')
}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}

View File

@@ -24,6 +24,7 @@ import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
import type {
AgentPathInfo,
@@ -131,17 +132,18 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
[externalAgents],
);
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
() => splitClaudeEnv(claudeManagedEnv),
[claudeManagedEnv],
);
const {
configDir: claudeConfigDir,
settingsPath: claudeSettingsPath,
envText: claudeEnvText,
} = useMemo(() => splitClaudeEnv(claudeManagedEnv), [claudeManagedEnv]);
const updateClaudeEnv = useCallback(
(nextConfigDir: string, nextEnvText: string) => {
(nextConfigDir: string, nextSettingsPath: string, nextEnvText: string) => {
setExternalAgents((prev) =>
prev.map((a) =>
a.id === "discovered_claude"
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextSettingsPath, nextEnvText) }
: a,
),
);
@@ -276,10 +278,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const agentOptions = useMemo(() => [
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
...externalAgents
.filter((a) => a.enabled)
.filter((a) => canSendWithAgent(a.id, externalAgents))
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
useEffect(() => {
if (!agentOptions.some((option) => option.value === defaultAgentId)) {
setDefaultAgentId("catty");
}
}, [agentOptions, defaultAgentId, setDefaultAgentId]);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
@@ -567,9 +575,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
configDir={claudeConfigDir}
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
onConfigDirChange={(v) => updateClaudeEnv(v, claudeSettingsPath, claudeEnvText)}
settingsPath={claudeSettingsPath}
onSettingsPathChange={(v) => updateClaudeEnv(claudeConfigDir, v, claudeEnvText)}
envText={claudeEnvText}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, claudeSettingsPath, v)}
/>
</div>

View File

@@ -45,6 +45,13 @@ interface TempDirInfo {
totalSize: number;
}
interface SshDebugLogInfo {
enabled: boolean;
path: string;
exists: boolean;
size: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
@@ -76,6 +83,8 @@ interface SettingsSystemTabProps {
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
sshDebugLogsEnabled: boolean;
setSshDebugLogsEnabled: (enabled: boolean) => void;
toggleWindowHotkey: string;
setToggleWindowHotkey: (hotkey: string) => void;
closeToTray: boolean;
@@ -100,6 +109,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
@@ -132,6 +143,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
const [appVersion, setAppVersion] = useState('');
@@ -196,6 +209,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
void loadCrashLogs();
}, [loadCrashLogs]);
const loadSshDebugLogInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getSshDebugLogInfo) return;
setIsLoadingSshDebugLogInfo(true);
try {
const info = await bridge.getSshDebugLogInfo();
setSshDebugLogInfo(info);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load SSH debug log info:", err);
} finally {
setIsLoadingSshDebugLogInfo(false);
}
}, []);
useEffect(() => {
void loadSshDebugLogInfo();
}, [loadSshDebugLogInfo, sshDebugLogsEnabled]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
@@ -294,6 +325,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, [sessionLogsDir]);
const handleOpenSshDebugLogDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openSshDebugLogDir) return;
await bridge.openSshDebugLogDir();
}, []);
// Handle global toggle hotkey recording
const cancelHotkeyRecording = useCallback(() => {
setIsRecordingHotkey(false);
@@ -877,6 +914,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* SSH Debug Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sshDebugLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
<SettingRow
label={t("settings.sshDebugLogs.enable")}
description={t("settings.sshDebugLogs.enableDesc")}
>
<Toggle
checked={sshDebugLogsEnabled}
onChange={setSshDebugLogsEnabled}
/>
</SettingRow>
<div className="space-y-2">
<span className="text-sm font-medium">{t("settings.sshDebugLogs.location")}</span>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
{isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadSshDebugLogInfo}
disabled={isLoadingSshDebugLogInfo}
className="shrink-0 gap-1.5"
>
<RefreshCw size={14} className={isLoadingSshDebugLogInfo ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenSshDebugLogDir}
className="shrink-0"
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{t("settings.sshDebugLogs.status")}:{" "}
{sshDebugLogsEnabled ? t("settings.sshDebugLogs.statusOn") : t("settings.sshDebugLogs.statusOff")}
</span>
<span>
{t("settings.sshDebugLogs.size")}: {formatBytes(sshDebugLogInfo?.size ?? 0)}
</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sshDebugLogs.hint")}
</p>
</div>
{/* Global Toggle Window Section (Quake Mode) */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -12,6 +12,7 @@ import { customThemeStore, useCustomThemes } from "../../../application/state/cu
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
import { cn } from "../../../lib/utils";
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
import { parseShellArgs, formatShellArgs } from "../../../domain/shellArgs";
import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input";
@@ -85,12 +86,21 @@ export default function SettingsTerminalTab(props: {
});
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
const [customShellDraft, setCustomShellDraft] = useState("");
const [customArgsDraft, setCustomArgsDraft] = useState("");
// Update showCustomShellInput once discovered shells load
useEffect(() => {
if (!terminalSettings.localShell) return;
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
// Seed the drafts from current settings and open the custom-shell editor.
// Used both when picking "Custom…" and when re-editing an existing custom shell.
const openCustomShellModal = useCallback(() => {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomArgsDraft(formatShellArgs(terminalSettings.localShellArgs ?? []));
setCustomShellModalOpen(true);
}, [terminalSettings.localShell, terminalSettings.localShellArgs]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
@@ -682,14 +692,17 @@ export default function SettingsTerminalTab(props: {
}
onValueChange={(value) => {
if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true);
openCustomShellModal();
} else if (value === "__default__") {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", "");
// Custom args only apply to a custom path; clear them so a stale
// value can't leak into a discovered/default shell launch (#1221).
updateTerminalSetting("localShellArgs", []);
} else {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", value);
updateTerminalSetting("localShellArgs", []);
}
}}
>
@@ -710,9 +723,18 @@ export default function SettingsTerminalTab(props: {
</SelectContent>
</ShadcnSelect>
{showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell}
</span>
<button
type="button"
onClick={openCustomShellModal}
title={t("common.edit")}
className="flex items-center gap-1 text-xs text-muted-foreground max-w-48 hover:text-foreground"
>
<Pencil size={11} className="shrink-0" />
<span className="truncate">
{terminalSettings.localShell}
{terminalSettings.localShellArgs?.length ? ` ${formatShellArgs(terminalSettings.localShellArgs)}` : ""}
</span>
</button>
)}
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
@@ -930,6 +952,16 @@ export default function SettingsTerminalTab(props: {
</span>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customArgs")}</label>
<Input
value={customArgsDraft}
placeholder={t("settings.terminal.localShell.shell.customArgs.placeholder")}
onChange={(e) => setCustomArgsDraft(e.target.value)}
className="w-full"
/>
<span className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.customArgs.desc")}</span>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
<div className="flex flex-wrap gap-1.5">
@@ -958,6 +990,7 @@ export default function SettingsTerminalTab(props: {
type="button"
onClick={() => {
updateTerminalSetting("localShell", customShellDraft);
updateTerminalSetting("localShellArgs", parseShellArgs(customArgsDraft));
setShowCustomShellInput(true);
setCustomShellModalOpen(false);
}}

View File

@@ -7,6 +7,9 @@ import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const ADD_PROVIDER_MENU_CLASS =
"absolute top-full right-0 mt-1 z-[101] min-w-[220px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover shadow-md py-1";
export const AddProviderDropdown: React.FC<{
onAdd: (providerId: AIProviderId) => void;
}> = ({ onAdd }) => {
@@ -33,7 +36,7 @@ export const AddProviderDropdown: React.FC<{
{/* Backdrop */}
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
{/* Menu */}
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
<div className={ADD_PROVIDER_MENU_CLASS}>
{providerIds.map((pid) => (
<button
key={pid}

View File

@@ -15,6 +15,8 @@ export const ClaudeCodeCard: React.FC<{
onRecheckPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
settingsPath: string;
onSettingsPathChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
@@ -25,6 +27,8 @@ export const ClaudeCodeCard: React.FC<{
onRecheckPath,
configDir,
onConfigDirChange,
settingsPath,
onSettingsPathChange,
envText,
onEnvTextChange,
}) => {
@@ -33,7 +37,7 @@ export const ClaudeCodeCard: React.FC<{
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(configDir.trim() || envText.trim()),
() => Boolean(configDir.trim() || settingsPath.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
@@ -140,6 +144,18 @@ export const ClaudeCodeCard: React.FC<{
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-settings" className="text-xs text-muted-foreground">{t('ai.claude.settings')}</label>
<input
id="claude-settings"
type="text"
value={settingsPath}
onChange={(e) => onSettingsPathChange(e.target.value)}
placeholder={t('ai.claude.settings.placeholder')}
className="w-full 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"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.settings.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
@@ -9,19 +9,90 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
import { parseFetchedModels } from "./modelMetadata";
export function buildModelSuggestions({
presetModels,
fetchedModels,
hasFetched,
value,
}: {
presetModels?: readonly string[];
fetchedModels: FetchedModel[];
hasFetched: boolean;
value: string;
}): FetchedModel[] {
const byId = new Map<string, FetchedModel>();
for (const modelId of presetModels ?? []) {
const id = modelId.trim();
if (id) byId.set(id, { id });
}
if (hasFetched) {
for (const model of fetchedModels) {
byId.set(model.id, model);
}
}
const allSuggestions = Array.from(byId.values());
if (!value.trim()) return allSuggestions;
const q = value.toLowerCase();
return allSuggestions.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}
export function getModelSuggestionsPresentation({
suggestionsLength,
isLoading,
error,
hasFetched,
hasPresetModels,
}: {
suggestionsLength: number;
isLoading: boolean;
error: string | null;
hasFetched: boolean;
hasPresetModels: boolean;
}): {
showSuggestions: boolean;
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
footerState: "loading" | "error" | null;
} {
if (suggestionsLength > 0) {
return {
showSuggestions: true,
emptyState: null,
footerState: isLoading ? "loading" : error ? "error" : null,
};
}
if (isLoading) {
return { showSuggestions: false, emptyState: "loading", footerState: null };
}
if (error) {
return { showSuggestions: false, emptyState: "error", footerState: null };
}
return {
showSuggestions: false,
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
footerState: null,
};
}
export const ModelSelector: React.FC<{
value: string;
onChange: (value: string) => void;
baseURL: string;
modelsEndpoint?: string;
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, style, skipTLSVerify }) => {
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -39,11 +110,30 @@ export const ModelSelector: React.FC<{
// Ollama runs locally without auth; all other providers need an API key to list models
const needsApiKey = providerId !== "ollama";
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
const hasPresetModels = (presetModels?.length ?? 0) > 0;
const canSuggest = canFetch || hasPresetModels;
const discoveryKey = JSON.stringify({
baseURL,
effectiveModelsEndpoint,
apiKey,
resolvedStyle,
skipTLSVerify,
});
const discoveryKeyRef = useRef(discoveryKey);
useEffect(() => {
discoveryKeyRef.current = discoveryKey;
setModels([]);
setHasFetched(false);
setError(null);
setIsLoading(false);
}, [discoveryKey]);
const fetchModels = useCallback(async () => {
if (!effectiveModelsEndpoint) return;
const bridge = getFetchBridge();
if (!bridge?.aiFetch) return;
const requestKey = discoveryKey;
setIsLoading(true);
setError(null);
@@ -57,23 +147,23 @@ export const ModelSelector: React.FC<{
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
}
const parsed = JSON.parse(result.data);
const list: FetchedModel[] = (parsed.data || parsed.models || []).map((m: { id: string; name?: string }) => ({
id: m.id,
name: m.name,
}));
const list = parseFetchedModels(parsed);
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
if (discoveryKeyRef.current !== requestKey) return;
setModels(list);
setHasFetched(true);
} catch (err) {
if (discoveryKeyRef.current !== requestKey) return;
setError(err instanceof Error ? err.message : "Failed to parse response");
} finally {
setIsLoading(false);
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify]);
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
@@ -82,17 +172,24 @@ export const ModelSelector: React.FC<{
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
// Filter models by current input value (inline autocomplete)
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
if (!hasFetched || models.length === 0) return [];
if (!value.trim()) return models;
const q = value.toLowerCase();
return models.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}, [models, value, hasFetched]);
return buildModelSuggestions({
presetModels,
fetchedModels: models,
hasFetched,
value,
});
}, [models, presetModels, value, hasFetched]);
const showSuggestions = isOpen && canFetch;
const showSuggestions = isOpen && canSuggest;
const presentation = getModelSuggestionsPresentation({
suggestionsLength: suggestions.length,
isLoading,
error,
hasFetched,
hasPresetModels,
});
return (
<div className="relative">
@@ -103,17 +200,17 @@ export const ModelSelector: React.FC<{
value={value}
onChange={(e) => {
onChange(e.target.value);
if (canFetch && hasFetched && !isOpen) setIsOpen(true);
if (canSuggest && !isOpen) setIsOpen(true);
}}
onFocus={() => { if (canFetch) setIsOpen(true); }}
onFocus={() => { if (canSuggest) setIsOpen(true); }}
onBlur={() => { setIsOpen(false); }}
placeholder={placeholder ?? (canFetch ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
canFetch && "pr-8",
canSuggest && "pr-8",
)}
/>
{canFetch && (
{canSuggest && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
@@ -145,16 +242,20 @@ export const ModelSelector: React.FC<{
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
<div className="max-h-60 overflow-y-auto">
{isLoading ? (
{!presentation.showSuggestions ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</div>
) : error ? (
<div className="px-3 py-3 text-center text-xs text-destructive">{error}</div>
) : suggestions.length === 0 ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
{hasFetched ? t('ai.providers.noMatchingModels') : t('ai.providers.clickToLoadModels')}
{presentation.emptyState === "loading" ? (
<>
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</>
) : presentation.emptyState === "error" ? (
<span className="text-destructive">{error}</span>
) : presentation.emptyState === "noMatches" ? (
t('ai.providers.noMatchingModels')
) : (
t('ai.providers.clickToLoadModels')
)}
</div>
) : (
suggestions.slice(0, 100).map((m) => (
@@ -163,6 +264,7 @@ export const ModelSelector: React.FC<{
onMouseDown={(e) => {
e.preventDefault();
onChange(m.id);
onModelMetadata?.(m);
setIsOpen(false);
}}
className={cn(
@@ -175,6 +277,21 @@ export const ModelSelector: React.FC<{
</button>
))
)}
{presentation.footerState && (
<div className={cn(
"px-3 py-2 text-center text-[10px] border-t border-border/40",
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
)}>
{presentation.footerState === "loading" ? (
<>
<RefreshCw size={12} className="animate-spin inline mr-1" />
{t('ai.providers.loadingModels')}
</>
) : (
error
)}
</div>
)}
{suggestions.length > 100 && (
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
@@ -10,6 +11,7 @@ import type { BuiltinProviderIcon } from "./types";
import { BUILTIN_PROVIDER_ICONS } from "./types";
import type { ProviderFormState } from "./types";
import { ModelSelector } from "./ModelSelector";
import { mergeModelContextWindow } from "./modelMetadata";
import { ProviderIconBadge } from "./ProviderIconBadge";
const ICON_PIXEL_SIZE = 64;
@@ -60,6 +62,8 @@ export const ProviderConfigForm: React.FC<{
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "",
modelContextWindows: provider.modelContextWindows ?? {},
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
style: provider.style ?? "",
@@ -71,9 +75,28 @@ export const ProviderConfigForm: React.FC<{
const [showAdvanced, setShowAdvanced] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
const [iconError, setIconError] = useState<string | null>(null);
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
const preset = PROVIDER_PRESETS[provider.providerId];
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
providerId: provider.providerId,
baseURL: form.baseURL || preset?.defaultBaseURL || "",
modelsEndpoint: preset?.modelsEndpoint ?? "",
apiKeySourceVersion,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
}), [
provider.providerId,
form.baseURL,
apiKeySourceVersion,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const modelMetadataSourceKeyRef = useRef<string | null>(null);
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
providerId: provider.providerId,
name: form.name,
@@ -97,6 +120,19 @@ export const ProviderConfigForm: React.FC<{
}
}, [provider.apiKey]);
useEffect(() => {
if (modelMetadataSourceKeyRef.current == null) {
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
return;
}
if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return;
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
setForm((prev) => Object.keys(prev.modelContextWindows).length > 0
? { ...prev, modelContextWindows: {} }
: prev);
}, [modelMetadataSourceKey]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
@@ -139,6 +175,11 @@ export const ProviderConfigForm: React.FC<{
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
}, []);
const handleApiKeyChange = useCallback((value: string) => {
setApiKeySourceVersion((version) => version + 1);
setForm((prev) => ({ ...prev, apiKey: value }));
}, []);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
@@ -150,11 +191,25 @@ export const ProviderConfigForm: React.FC<{
const trimmedName = form.name.trim();
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
const rawContextWindow = form.contextWindow.trim();
const rawContextWindowNumber = Number(rawContextWindow);
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined;
if (rawContextWindow && manualContextWindow == null) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
setContextWindowError(null);
const updates: Partial<ProviderConfig> = {
name: trimmedName || defaultName,
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
contextWindow: manualContextWindow,
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
style: form.style || undefined,
@@ -170,7 +225,7 @@ export const ProviderConfigForm: React.FC<{
}
onSave(updates);
}, [form, onSave, provider.providerId]);
}, [form, onSave, provider.providerId, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
@@ -305,7 +360,7 @@ export const ProviderConfigForm: React.FC<{
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
@@ -339,8 +394,15 @@ export const ProviderConfigForm: React.FC<{
<ModelSelector
value={form.defaultModel}
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
onModelMetadata={(model) => {
setForm((prev) => ({
...prev,
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
}));
}}
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
providerId={provider.providerId}
style={resolvedStyle}
@@ -348,6 +410,32 @@ export const ProviderConfigForm: React.FC<{
/>
</div>
{/* Context window */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.contextWindow')}</label>
<input
type="number"
min={1}
step={1}
value={form.contextWindow}
onChange={(e) => {
setContextWindowError(null);
setForm((prev) => ({ ...prev, contextWindow: e.target.value }));
}}
placeholder={
form.defaultModel && form.modelContextWindows[form.defaultModel]
? String(form.modelContextWindows[form.defaultModel])
: t('ai.providers.contextWindow.placeholder')
}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
contextWindowError && "border-destructive focus-visible:ring-destructive",
)}
/>
{contextWindowError && <p className="text-[11px] text-destructive">{contextWindowError}</p>}
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.contextWindow.help')}</p>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input

View File

@@ -7,7 +7,12 @@
*/
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
// netcatty marker carrying the claude SDK `settings` option (a settings.json
// path or inline JSON). Extracted in the main process and passed to the SDK as
// `options.settings`; never sent to the agent as a real env var. Additive to —
// and independent of — CLAUDE_CONFIG_DIR.
const SETTINGS_KEY = "NETCATTY_CLAUDE_SETTINGS";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY, SETTINGS_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
@@ -31,20 +36,22 @@ export function serializeEnvLines(env: Record<string, string>): string {
export function splitClaudeEnv(
env: Record<string, string> | undefined,
): { configDir: string; envText: string } {
if (!env) return { configDir: "", envText: "" };
): { configDir: string; settingsPath: string; envText: string } {
if (!env) return { configDir: "", settingsPath: "", envText: "" };
const configDir = env[CONFIG_DIR_KEY] ?? "";
const settingsPath = env[SETTINGS_KEY] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { configDir, envText: serializeEnvLines(rest) };
return { configDir, settingsPath, envText: serializeEnvLines(rest) };
}
export function buildClaudeEnv(
prevEnv: Record<string, string> | undefined,
configDir: string,
settingsPath: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
@@ -55,8 +62,11 @@ export function buildClaudeEnv(
const trimmedDir = String(configDir || "").trim();
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
const trimmedSettings = String(settingsPath || "").trim();
if (trimmedSettings) next[SETTINGS_KEY] = trimmedSettings;
// Drop managed keys if a user typed them into the free-text editor — the
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
// dedicated fields and path discovery own these keys.
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ADD_PROVIDER_MENU_CLASS } from "./AddProviderDropdown.tsx";
import { getModelSuggestionsPresentation } from "./ModelSelector.tsx";
test("add provider menu opens toward the left edge of the button and stays width-bounded", () => {
assert.match(ADD_PROVIDER_MENU_CLASS, /right-0/);
assert.doesNotMatch(ADD_PROVIDER_MENU_CLASS, /left-0/);
assert.match(ADD_PROVIDER_MENU_CLASS, /max-w-\[calc\(100vw-2rem\)\]/);
});
test("preset model suggestions stay visible while remote models are loading", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: true,
error: null,
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "loading" },
);
});
test("preset model suggestions stay visible when remote model discovery fails", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: false,
error: "Failed to fetch models",
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "error" },
);
});

View File

@@ -46,12 +46,17 @@ export function buildManagedAgentState(
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const {
acpCommand: _legacyCommand,
acpArgs: _legacyArgs,
...existingManagedWithoutLegacy
} = existingManaged ?? {};
const defaults = AGENT_DEFAULTS[agentKey];
const managedEnv = agentKey === "claude"
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
: existingManaged?.env;
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...existingManagedWithoutLegacy,
...defaults,
id: managedId,
command: pathInfo.path,

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
mergeModelContextWindow,
parseFetchedModels,
} from "./modelMetadata.ts";
import { buildModelSuggestions } from "./ModelSelector.tsx";
test("parseFetchedModels reads common context window fields from model list responses", () => {
assert.deepEqual(
parseFetchedModels({
data: [
{ id: "openrouter/model", name: "OpenRouter Model", context_length: 131072 },
{ id: "vercel/model", context_window: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
}),
[
{ id: "openrouter/model", name: "OpenRouter Model", contextWindow: 131072 },
{ id: "vercel/model", contextWindow: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
);
});
test("mergeModelContextWindow stores valid discovered model windows only", () => {
assert.deepEqual(
mergeModelContextWindow(undefined, "qwen", 262144),
{ qwen: 262144 },
);
assert.deepEqual(
mergeModelContextWindow({ old: 8192 }, "qwen", undefined),
{ old: 8192 },
);
});
test("buildModelSuggestions uses provider presets before fetched model discovery", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["qwen3.6-plus", "qwen3.6-flash"],
fetchedModels: [],
hasFetched: false,
value: "plus",
}),
[{ id: "qwen3.6-plus" }],
);
});
test("buildModelSuggestions merges fetched and preset models without duplicates", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["kimi-k2.6", "moonshot-v1-128k"],
fetchedModels: [
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "moonshot-v1-8k", name: "Moonshot 8K" },
],
hasFetched: true,
value: "",
}).map((model) => model.id),
["kimi-k2.6", "moonshot-v1-128k", "moonshot-v1-8k"],
);
});

View File

@@ -0,0 +1,44 @@
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import type { FetchedModel } from "./types";
export function parseFetchedModels(parsed: unknown): FetchedModel[] {
const record = parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
const rawModels = Array.isArray(record.data)
? record.data
: Array.isArray(record.models)
? record.models
: [];
return rawModels
.map((raw): FetchedModel | null => {
if (!raw || typeof raw !== "object") return null;
const model = raw as Record<string, unknown>;
if (typeof model.id !== "string" || !model.id) return null;
return {
id: model.id,
...(typeof model.name === "string" ? { name: model.name } : {}),
...(resolveModelContextWindow(model) != null ? { contextWindow: resolveModelContextWindow(model) } : {}),
};
})
.filter((model): model is FetchedModel => model != null);
}
export function mergeModelContextWindow(
current: Record<string, number> | undefined,
modelId: string,
contextWindow: number | undefined,
): Record<string, number> | undefined {
const sanitized = sanitizeContextWindow(contextWindow);
if (!modelId || sanitized == null) return current;
return { ...(current ?? {}), [modelId]: sanitized };
}
function resolveModelContextWindow(model: Record<string, unknown>): number | undefined {
return sanitizeContextWindow(
model.context_length
?? model.context_window
?? model.contextWindow
?? model.context
?? model.max_context_tokens,
);
}

View File

@@ -78,6 +78,8 @@ export interface ProviderFormState {
apiKey: string;
baseURL: string;
defaultModel: string;
contextWindow: string;
modelContextWindows: Record<string, number>;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
style: ProviderStyle | ""; // "" means inherit-from-providerId
@@ -88,6 +90,7 @@ export interface ProviderFormState {
export interface FetchedModel {
id: string;
name?: string;
contextWindow?: number;
}
export interface FetchBridge {
@@ -113,22 +116,19 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
name: "Codex CLI",
args: ["exec", "--full-auto", "--json", "{prompt}"],
icon: "openai",
acpCommand: "codex-acp",
acpArgs: [],
sdkBackend: "codex",
},
claude: {
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
acpCommand: "claude-agent-acp",
acpArgs: [],
sdkBackend: "claude",
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
sdkBackend: "copilot",
},
};
@@ -166,6 +166,12 @@ export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
qwen: "/ai/providers/qwen.svg",
deepseek: "/ai/providers/deepseek.svg",
kimi: "/ai/providers/kimi.svg",
zhipu: "/ai/providers/zhipu.svg",
doubao: "/ai/providers/doubao.svg",
mimo: "/ai/providers/xiaomi.svg",
custom: "/ai/providers/custom.svg",
};
@@ -177,6 +183,12 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",
qwen: "bg-[#615CED]",
deepseek: "bg-[#4D6BFE]",
kimi: "bg-zinc-800",
zhipu: "bg-[#3859FF]",
doubao: "bg-[#0066FF]",
mimo: "bg-[#FF6900]",
custom: "bg-zinc-600",
};
@@ -210,6 +222,7 @@ export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
{ id: "xiaomi", label: "Xiaomi / 小米", name: "Xiaomi MiMo", path: "/ai/providers/xiaomi.svg", bgColor: "bg-[#FF6900]" },
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },

View File

@@ -0,0 +1,95 @@
import React, { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import type { SftpClipboardUploadRequest } from "./clipboardUpload";
import { sftpClipboardUploadStore } from "./clipboardUpload";
interface SftpClipboardUploadDialogProps {
request: SftpClipboardUploadRequest | null;
currentPath?: string;
onUploaded?: (targetPath: string) => void;
}
export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps> = ({
request,
currentPath,
onUploaded,
}) => {
const [uploading, setUploading] = useState(false);
const open = !!request;
const fileCount = request?.files.length ?? 0;
const previewFiles = request?.files.slice(0, 5) ?? [];
const remainingCount = Math.max(0, fileCount - previewFiles.length);
const handleClose = (nextOpen: boolean) => {
if (nextOpen || uploading) return;
sftpClipboardUploadStore.clear(request);
};
const handleConfirm = async () => {
if (!request) return;
setUploading(true);
try {
await request.onConfirm();
onUploaded?.(request.targetPath);
sftpClipboardUploadStore.clear(request);
} finally {
setUploading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Upload clipboard files?</DialogTitle>
<DialogDescription>
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all">
{request?.targetPath ?? currentPath}
</div>
{previewFiles.length > 0 && (
<div className="max-h-40 overflow-auto rounded-md border border-border/60">
{previewFiles.map((file) => (
<div key={file.path} className="px-3 py-2 text-sm border-b border-border/40 last:border-b-0 truncate">
{file.name}
</div>
))}
{remainingCount > 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
and {remainingCount} more...
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => sftpClipboardUploadStore.clear(request)}
disabled={uploading}
>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={uploading || !request}>
{uploading && <Loader2 size={14} className="mr-2 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -50,6 +50,7 @@ export interface SftpPaneCallbacks {
// File operations
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes

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