Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 62eb8cbf42 fix(ai): restore the OpenCode 2 server username
The transport-sanitizer edit replaced the opencode username constant, so a real 2.x spawn threw ReferenceError before Basic auth. Put the constant back and lock the default client username.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:31:57 +00:00
Cursor Agentand陈大猫 f56cc29afe fix(ai): keep OpenCode transport mapping to mid-stream failures
Server-authored tool and session messages are no longer rewritten just because they match a transport string. A dead reply stream still reports the localized session-failed key.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:26:02 +00:00
Cursor Agentand陈大猫 0a50705bb5 fix(ai): drop undici transport text from OpenCode 2 details
HTTP error bodies and tool failures share the same sanitizer as session errors, so fetch failed, terminated, and other side closed never reach the chat.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:22:49 +00:00
Cursor Agentand陈大猫 24253d6406 fix(ai): keep caller OpenCode config and hide transport session errors
A profile's OPENCODE_CONFIG_CONTENT is merged under Netcatty's generated settings, and a server-reported fetch failure no longer reaches the chat as raw transport text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:17:46 +00:00
Cursor Agentand陈大猫 17b113091c fix(ai): keep the OpenCode 2 config model as a qualified string
The selected provider/model stays the original string in OPENCODE_CONFIG_CONTENT. Session create still sends Model.Ref with id and providerID.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:10:28 +00:00
Cursor Agentand陈大猫 e47378614a fix(ai): ignore Bun version banners and hide undici transport errors
A crashed Bun binary was cached as OpenCode 1.x, and fetch or JSON-body failures still showed raw undici text. Skip Bun and Node.js runtime versions, and map those transport errors to the existing unavailable message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:02:03 +00:00
Cursor Agentand陈大猫 3a190e6a9e fix(ai): localize OpenCode 2 failures when the server dies mid-turn
A killed event stream was surfacing undici's raw "terminated" text. Map that disconnect, a closed socket, and an SSE stream that ends before the turn finishes to the existing session-failed message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:54:18 +00:00
Cursor Agentand陈大猫 c9e22779a1 fix(ai): refetch the OpenCode catalog when the settlement budget elapses during a read
A catalog read that outlasts the quiet-window budget still has to fetch the snapshot announced by an in-flight model or provider update.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:28:43 +00:00
Cursor Agentand陈大猫 7c278f1c55 fix(ai): refetch the OpenCode catalog when it changes during the first read
An update that arrives while the initial model list is in flight belongs to a newer snapshot. Record the event count before that request and fetch again when it moves.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:20:55 +00:00
Cursor Agentand陈大猫 0ce1eb88be fix(ai): refresh OpenCode version and catalog after startup races
Key the version cache by the resolved PATH binary and its mtime, so an in-place upgrade is not stuck on the previous major. While listing 2.x models, refetch after model.updated or provider.updated until the snapshot stays quiet.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:52 +00:00
Cursor Agentand陈大猫 ceea6f2758 fix(ai): post OpenCode 2 prompts after the event stream is open
The live /api/event stream does not replay. Wait until the server accepts the subscriber and start reading it before sending the user prompt.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:06:15 +00:00
Cursor Agentand陈大猫 6d911712a6 fix(ai): select OpenCode 2 catalog ids and keep context optional
Aliases expose provider/catalog id rather than the provider API model id. A rejected instruction entry warns and still sends the user prompt. The entry value stays the system-prompt string, which is valid JSON for that endpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:59:28 +00:00
Cursor Agentand陈大猫 69a8b1a0a2 fix(ai): stop the OpenCode 2 event stream when a prompt is rejected
A rejected prompt does not emit a terminal SSE event. Abort that stream and close the server before waiting, and keep HTTP "not found" responses from being reported as a missing CLI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:52:13 +00:00
Cursor Agentand陈大猫 24f390c332 feat(ai): support OpenCode 2.x while keeping the 1.x driver
Detect the installed opencode major version. 1.x stays on the bundled SDK and the old startup banner. 2.x uses the new serve banner, HTTP Basic auth, and the session API so model lists and chat turns work.

Fixes #3799

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:46:15 +00:00
cd7f182401 feat(ssh): host TOTP with QR scan and MFA auto-fill (#3323) (#3788)
* feat(ssh): add per-host TOTP with QR scan and MFA auto-fill

Store an optional otpauth TOTP secret on the host, encrypt and sync it
like a password, and auto-submit it once when a keyboard-interactive
prompt matches JumpServer or PAM TOTP text.

Closes #3323
Refs #3415

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): address Codex TOTP review findings

Reset authenticator editor state when the host changes, match custom
prompts with RE2, offer keyboard-interactive during connection tests
when TOTP is configured, and drop the secret when a host becomes a plugin.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): fill TOTP from the active prompt and ship re2js

Attach the connection's otpauth URI to the keyboard-interactive dialog
so Fill current code uses that attempt, including unsaved hosts. Move
re2js into production dependencies so packaged builds can match custom prompts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): ship the shared TOTP runtime in packaged builds

electron-builder allowlists domain CommonJS modules individually, and
sshAuthHelper requires domain/totp.shared.cjs at load.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): save a pasted TOTP secret and ignore stale QR imports

Host save waits for the authenticator draft to validate before writing
the host. QR import completions from the previous host no longer change
busy state or errors on the newly selected host.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(host): ignore TOTP copy in the proxy command summary check

The assertion was matching the word secret in the authenticator UI.
It now checks that the ProxyCommand token itself is not rendered.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(ssh): simplify host TOTP setup and type codes into the terminal

Move authenticator setup into a dialog so the host form keeps a single setting row, show keyboard-interactive prompts as field labels, and add a terminal context-menu action that types the current code without Enter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill inside the auth dialog

Remove the terminal context-menu action. Offer the verification-code button on each non-password prompt, highlight OTP prompts, and never write the code into the password field.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep MFA instructions and scan every screen

Show keyboard-interactive name and instructions as plain text when they are not already the field label, and scan each unmatched desktop source once instead of repeating the first display.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep multiline MFA prompt directions

Show lines before the last keyboard-interactive prompt as plain-text guidance, and keep that last line as the field label.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): scan TOTP QR codes at each display's full size

Request a thumbnail as wide as the widest display and as tall as the tallest, so a portrait screen is not scaled into a landscape frame.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill off menu prompts

Judge the fill action from the last prompt line and the shared menu, SMS, and confirmation exclusion list. Use a secondary button on the label row, and simplify the host authenticator row.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 19:22:05 +08:00
260b06540f fix(terminal): keep broadcast on during bash PS2 heredoc paste (#3798)
* fix(terminal): treat bare bash PS2 as a confirmed shell prompt

Broadcast input latched on the default ">" continuation prompt, so the
final Enter of a heredoc paste reached only the focused session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): trust bare PS2 only inside a heredoc

A global bare ">" confirmation cleared the sensitive-input latch for
echo-disabled prompts such as read -s -p '> '. Trust that line only when
the preceding command is a confirmed shell prompt containing <<.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): coalesce a CRLF split across prompt-security chunks

A chunk that ends in CR completes the heredoc opener. The next chunk's
leading LF is the rest of that break, so dropping it keeps the opener
and a following bare PS2 stays trusted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 19:20:19 +08:00
57089a3abf feat: temporary network device mode and tab-bar quick connect (#3793)
* feat(ssh): opt temporary connections into network device mode

Saved hosts and group-backed clones keep their own device type. SSH
connections with no saved host, including external launches and quick
connect, can start in network device mode when the new Terminal setting
is on. The setting stays off unless the user turns it on.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(terminal): quick connect typed addresses from the tab switcher

The tab-bar quick switcher now offers the typed address when it already
parses as a connection target. Choosing that row opens the existing
quick connect wizard, including from a terminal tab where the vault
surface is hidden.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep temporary Mosh and ET off network device mode

Those transports are stored as SSH plus a flag. Stamping network device
mode onto them made raw-host checks skip the copy-session working
directory probe. Plain temporary SSH still opts in.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): read temporary connection settings at connect time

VaultView keeps a stale onConnect callback because its memo ignores that
prop. Read both temporary-connection toggles from a ref when connecting
so turning them on or off applies in the same session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): stop word queries from opening quick connect

Show the switcher quick-connect row only for address-like input, and
place it after matching hosts so Enter still connects to a saved host.
Translate the Hosts, Tabs, and New Workspace labels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep matching tabs ahead of quick connect

An address that matches an open session or workspace was still the
default Enter target when no saved host matched. Put that row after
those tabs so Enter switches to the existing one.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 18:29:41 +08:00
3c67392455 fix(ui): keep New Host menu icons from shrinking and fix toast contrast when app/system themes differ (#3795)
* fix(vault): keep the New Host local shell icon at 14px

The dropdown was a fixed w-44 flex row. "Local Shell (CMD)" is wider than
the sibling labels, so the icon SVG flex-shrunk below size 14 (and longer
shell names collapsed it entirely). Pin every menu icon with shrink-0 and
let the menu grow with the label.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ui): keep toast text on the app theme surface

Palette fills used a prefers-color-scheme dark variant, so an app-dark session with a light OS left light text on a pale background.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 18:14:11 +08:00
a01563931b feat(ssh): opt in legacy algorithms for temporary connections (#3792)
Saved hosts keep their own setting. External launches and quick connect
with no saved host can use the same legacy algorithm list when the new
global setting is turned on. It stays off by default.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 16:40:12 +08:00
0ccafbabf7 feat: optional Streamable HTTP for External MCP (#3790)
* feat: add optional Streamable HTTP for External MCP

Serve the existing catalog tools and approval path on a loopback HTTP
endpoint so clients that cannot spawn the stdio launcher, such as Codex
in WSL, can connect with a bearer token. The listener stays off until
the user enables it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: close HTTP sockets before stop and export the Codex bearer

Destroy tracked connections before awaiting server.close so disable does not hang on a half-open request. Prefix the Codex snippet with export so the child process receives the token.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: advertise a real client host and return 413 intact

Wildcard binds no longer copy 127.0.0.1 into client configs; the URL stays empty until a reachable client address is set. Oversized POSTs receive a JSON-RPC 413 before the socket is closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: recheck the bearer token after the HTTP body arrives

A half-open POST already passed auth before readBody. Recheck the live token before dispatch so a rotation during that wait cannot still run a tool.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: expire idle Streamable HTTP sessions

JSON responses do not keep a socket open, so a client that never sends DELETE used to pin an MCP session until the listener stopped. Sweep sessions that have been idle for 15 minutes, and skip any session with a request still in flight.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: return 404 when an HTTP session id is unknown

GET and DELETE with a nonempty session id that is no longer active now return 404, matching POST and the Streamable HTTP session contract. A missing session id stays 400.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: accept bracketed IPv6 origins

Node reports URL.hostname as [::1], so an otherwise valid loopback or wildcard Origin was rejected. Compare the address with the brackets removed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: release rejected HTTP handshakes that never open a session

A 406 or 415 from the SDK returns normally before onsessioninitialized. Detach the approval client and close the MCP server when that happens so rejected initializes do not accumulate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 16:39:56 +08:00
cd0fa39f49 feat(terminal): click archive names to fill extraction command (#3269) (#3787)
* feat(terminal): click archive names to fill extraction command

Paste an absolute tar or unzip command when a Linux session
Ctrl/Cmd-clicks a printed archive name. Cwd comes from OSC 7
markers only, so a later directory change cannot retarget an
old relative name.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): anchor wrapped archive links on the continuation row

A token that starts on the next physical row was mapped to the previous
row's exclusive end cell. Inclusive offsets now move to the next segment,
while an end-exclusive offset stays on the row where the match stops.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): separate archive fill from text after the cursor

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): separate archive fill across a wrapped draft

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 16:39:44 +08:00
陈大猫andGOUKI9999 c78121888b fix(cli): retry bridge connection with refreshed discovery (#3786)
The tool CLI now re-reads discovery.json for up to ~1.2s (300ms + 900ms) when it
connects during an app restart. It covers a missing or partially written
file, a dead port, and a stale token (AUTH_FAILED or "Authentication
required"), plus a socket that closes during auth/verify. Identity is
port + token + pid, so a bridge restarted on the same port is retried. When
every attempt fails, the original error is surfaced unchanged.

Supersedes #3785. Refs #3443.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 15:56:02 +08:00
f1be47adc4 fix(sftp): refresh local file metadata before copy (#3694)
* fix(sftp): refresh local file metadata before copy

* fix: address Codex review on PR #3694

* fix: address Codex review on PR #3694

* Revert "fix: address Codex review on PR #3694"

This reverts commit 2848321c61.

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-20 15:03:11 +08:00
陈大猫 2638d199f2 fix(terminal): keep mid-line snippet inserts and offer snippets at program prompts (#3769)
Mid-line snippet accept deletes only the current word (DEL on POSIX, backspace on Windows), and program prompts like Email:/Username: offer snippet candidates while password/passphrase prompts stay blocked.

Fixes #3622
2026-10-20 14:19:59 +08:00
44b970fc72 fix(terminal): forward Sogou latin commits after keyCode 229 (#3782)
* fix(terminal): forward Sogou latin commits after keyCode 229

Windows Sogou in Chinese mode commits latin text with a late insertText
after keyCode 229 and no composition events. xterm's 0ms check misses
that text, then _keyDownSeen drops the input so only CR reaches the PTY.

Fixes #3185

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): do not resend IME text the deferral already committed

A punctuation deferral that outlives its keyup still commits the later
insertText. The keyCode 229 guard was clearing xterm's dedupe flag, so
the same text was written again.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:15:59 +08:00
a03d41ebf5 fix(terminal): migrate saved Shift+Enter newline and honor xterm CSI u (#3775)
* fix(terminal): migrate the saved Shift+Enter newline once

Existing installs kept the pre-#3770 "\\n" default, so Shift+Enter still
submitted in TUI CLIs. Rewrite only that stored value to ESC+CR, and let
xterm's native CSI u own the chord when an app has opted in.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep the Shift+Enter migration local

The one-time rewrite of a saved "\n" does not need a sync flag or a
three-way merge. An imported or synced old newline can stay as stored.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep native kitty Shift+Enter on broadcast peers

When a target has negotiated xterm kitty keyboard and the custom
protocol setting is off, resolve the broadcast chord as CSI 13;2u
instead of the ESC+CR fallback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:53:52 +08:00
00c4095e59 feat: global default proxy with explicit direct override (#3774)
* feat: add a global default proxy with an explicit direct override

Resolve the proxy as the host setting, then the nearest group default,
then one optional saved profile from synced settings, then a direct
connection. A host can choose direct and skip both inherited defaults.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: keep global default proxy persistence across settings instances

Unregister only the setter owned by the unmounting useSettingsState instance so closing Settings still writes the cleared default through the remaining AppLockGate setter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: round-trip explicit direct mode in vault CSV

Write the Proxy cell as direct when a host sets directProxy, and import that literal back as an explicit direct connection. An empty Proxy cell still inherits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: keep CSV proxy export from embedding the global default

Grouped hosts ran through applyGroupDefaults and picked up the ambient
global proxy, while ungrouped hosts exported an empty inheriting cell.
Both paths now opt out of that default so inheritance stays inheritance.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: follow global default proxy changes in peer session windows

Session windows disable settings IPC sync, which also skipped the
storage listener and left the resolver singleton at its mount-time id.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* refactor: clear a deleted global default proxy at the call site

Drop the pass-through field on removeProxyProfileReferences. The profile
manager already knows the active id and clears it directly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test: share the global default proxy settings harness

The peer-window case and the multi-instance cleanup case were mounting
the same JSDOM fixture twice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:52:32 +08:00
aebe9944ed feat: inherit X11 forwarding from group defaults (#3777)
* feat: inherit X11 forwarding from group defaults

Hosts that leave the toggle unset pick up the nearest group's X11
forwarding setting through applyGroupDefaults. An explicit host value
still wins, and the default stays off.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: show inherited X11 from the effective host transport

The host editor treated a raw group X11 default as on even when
applyGroupDefaults suppresses that inheritance for Mosh, ET, or a
non-SSH protocol. Drive the switch and its visibility from that result.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:31:02 +08:00
8fdca2baea feat: add --open-terminal-shell launch flag (#3778)
* feat: add --open-terminal-shell launch flag

Queue local-terminal launches as { path?, shell? } so a discovered shell
id or executable can open a tab on its own or with --open-terminal-path.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: serialize open-terminal launches during shell discovery

Await shell discovery on an ordered queue so a later path-only request
cannot create its tab first, including when App Lock drains.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:27:48 +08:00
39e4e08d9c fix(terminal): skip paste residual EL on the alternate screen (#3776)
* fix(terminal): skip paste residual EL on the alternate screen

Vim often finishes a paste chunk with the cursor mid-line. Injecting
ESC[K there erases the rest of the row and is not redrawn. Clear the
pending flag so the cleanup cannot run after leaving the alt screen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): skip paste EL after a same-batch alt-screen exit

Remember the pre-write alternate buffer so a paste echo and ESC[?1049l
in one chunk cannot inject ESC[K] into the restored normal screen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:27:39 +08:00
陈大猫 3ef402cc71 fix(terminal): restore local shell cwd without OSC 7 (#3784)
Fixes #1952. When restore-terminal-cwd is on, local POSIX shells probe the PTY process cwd (/proc on Linux, lsof on macOS) and re-probe when the prompt returns so slow commands like `sleep 1; cd /tmp` land correctly. SSH restore path unchanged.
2026-10-20 13:15:32 +08:00
ee7b8fae6a fix(ai): keep shell-kind probe timeouts off the terminal SSH connection (#3783)
A 3s login-shell probe on the interactive client called Client.end() when
the exec channel failed to open, dropping every tab on that connection.
Preserve the terminal transport and keep teardown for dedicated Mosh/ET
helpers.

Fixes #1181

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:59:08 +08:00
604e11d239 feat(terminal): show per-GPU usage in the server stats bar (#3781)
* feat(terminal): show per-GPU usage in the server stats bar

Keep the existing single nvidia-smi poll and carry each card's index, name,
utilization, and VRAM into the same hover-card layout used for CPU cores.

Closes #3745

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): wrap GPU names and stretch a single card

Names use two lines plus the native title instead of an 88px clip, and one
GPU fills the hover card so the bar sits with the title.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:54:49 +08:00
陈大猫 04d71be651 feat(autocomplete): show zh-TW descriptions for common commands (#3780)
Follow-up to #3773: Traditional Chinese (Taiwan) autocomplete description dictionary via OpenCC s2twp, lazy-loaded like zh-CN.

Closes #3157.

Real-app smoke PASS on 4d2bd32e.
2026-10-20 12:41:43 +08:00
陈大猫 5ca36721b5 fix(ai): show MCP tool names on External MCP approval alerts (#3779)
Follow-up to #3771: notification/card label maps internal rpc ids (e.g. netcatty/exec) to catalog MCP tool names (terminal_execute).

Real-app smoke PASS on 80c28d8e.
2026-10-20 12:41:41 +08:00
a3083a4ac2 feat(autocomplete): show zh-CN descriptions for common commands (#3773)
Overlay bundled Fig description strings when the UI locale is zh-CN.
Cached specs stay in English, and missing entries fall back to the original text.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:59:42 +08:00
b2503ff6ba feat(ai): alert when an External MCP approval is waiting (#3771)
* feat: alert when an external MCP approval is waiting

Show a system notification and request taskbar or Dock attention so confirm-mode approvals are not missed before the existing timeout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: stop MCP approval attention when a later window focuses

Watch app browser-window-focus while a flash or dock bounce is active so a
window created after the alert starts can clear it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:54:44 +08:00
234 changed files with 25410 additions and 442 deletions

No files matched your search

+1
View File
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **セッション管理** — 複数の接続を並行して扱う
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
+20
View File
@@ -87,6 +87,7 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
- [Split Terminals](#split-terminals)
- [Supported Distros](#supported-distros)
- [Getting Started](#getting-started)
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
@@ -132,6 +133,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
@@ -241,6 +243,24 @@ application and integration are complete.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
<a name="open-a-local-terminal-from-the-command-line"></a>
### Open a local terminal from the command line
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
Windows needs an extra `--` so Electron forwards the flags:
```bat
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
```
On macOS, start a new instance with `open`, or run the binary directly:
```bash
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
```
### Scoop (Windows x64, community-maintained)
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
+1
View File
@@ -130,6 +130,7 @@
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
+1
View File
@@ -130,6 +130,7 @@
- **分割窗格** —— 水平與垂直分割,方便多工
- **工作階段管理** —— 多條連線並排處理
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
+4 -3
View File
@@ -534,9 +534,10 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
if (!source) return undefined;
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
// source that reflects `cd`s in a running local terminal — lastCwd is a
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the
// post-command probe, including local shells). lastCwd on the session object
// is only the startup snapshot. Falls through to the SSH probe / lastCwd
// when absent.
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
const bridge = netcattyBridge?.get?.();
@@ -15,4 +15,14 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
const processStart = source.indexOf("const _processOpenTerminalPath");
const processSource = source.slice(processStart, handlerStart);
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
const createAt = processSource.indexOf("handleCreateLocalTerminal");
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
});
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("handleConnectToHost reads temporary connection settings at call time", () => {
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
const handler = source.slice(handlerStart, handlerEnd);
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
assert.match(handler, /terminalSettings: liveTerminalSettings/);
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
});
+56 -13
View File
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -94,6 +95,7 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
@@ -1447,13 +1449,24 @@ export function AppSideEffects() {
name,
), [createWorkspaceFromTargets, resolveEffectiveHost]);
// VaultView memo ignores onConnect identity, so this callback can stay
// closed over an older render. Temporary-connection toggles are read here,
// at connect time, so turning them on or off applies without a reload.
const temporaryConnectionSettingsRef = useRef(terminalSettings);
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
if (host.ephemeral) {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
temporaryConnectionPrepareOptions(liveTerminalSettings),
);
if (connectionHost.ephemeral) {
setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
if (existingIndex < 0) return [...previous, connectionHost];
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
});
}
const effectiveHostResolver = alreadyEffective
@@ -1463,16 +1476,16 @@ export function AppSideEffects() {
addConnectionLog,
connectToHost,
discoveredShells,
host,
host: connectionHost,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
@@ -1557,7 +1570,7 @@ export function AppSideEffects() {
| { kind: 'ssh'; payload: DeepLinkPayload }
| { kind: 'telnet'; payload: DeepLinkPayload }
| { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
@@ -1721,14 +1734,44 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
// Shell discovery is part of this queue. A later path-only request, including
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
void openTerminalQueueRef.current.enqueue(() => {
if (!shellSetting) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
return (async () => {
const shells = await ensureDiscoveredShells();
const resolved = resolveShellSetting(shellSetting, shells);
if (!resolved?.command) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
const matchedShell = shells.find((shell) => shell.id === shellSetting);
handleCreateLocalTerminal(
{
command: resolved.command,
args: resolved.args,
name: matchedShell?.name,
icon: matchedShell?.icon,
},
localStartDir.trim() ? { localStartDir } : undefined,
);
})();
});
});
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path',
+12
View File
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { toast } from '../../components/ui/toast';
import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
@@ -376,6 +377,14 @@ function AppViewInner({ domains }: AppViewProps) {
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
const handleFillTotp = useCallback(async () => {
if (!pendingTotpUri) return null;
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
return result?.ok && result.code ? result.code : null;
}, [pendingTotpUri]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -930,6 +939,7 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
@@ -1022,6 +1032,8 @@ function AppViewInner({ domains }: AppViewProps) {
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
canFillTotp={Boolean(pendingTotpUri)}
onFillTotp={handleFillTotp}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOpenTerminalQueue } from './openTerminalQueue';
test('idle open-terminal work stays in call order on the caller turn', () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
queue.enqueue(() => { order.push('path-a'); });
queue.enqueue(() => { order.push('path-b'); });
assert.deepEqual(order, ['path-a', 'path-b']);
});
test('a path-only open waits for an in-flight shell discovery', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
let releaseDiscovery: () => void = () => {};
const discovery = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
const shellRequest = queue.enqueue(async () => {
order.push('shell');
await discovery;
order.push('shell-tab');
});
const pathRequest = queue.enqueue(() => {
order.push('path-tab');
});
assert.deepEqual(order, ['shell']);
releaseDiscovery();
await shellRequest;
await pathRequest;
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
});
test('a failed open-terminal request does not block the next one', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
const failed = queue.enqueue(async () => {
order.push('shell');
throw new Error('discovery failed');
});
const next = queue.enqueue(() => {
order.push('path-tab');
});
await assert.rejects(failed, /discovery failed/);
await next;
assert.deepEqual(order, ['shell', 'path-tab']);
});
+50
View File
@@ -0,0 +1,50 @@
type OpenTerminalTask = () => Promise<void> | void;
function isPromise(value: Promise<void> | void): value is Promise<void> {
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
}
/**
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
* path-only open still creates its tab synchronously. A task that awaits (shell
* discovery) holds the tail, and later requests cannot pass it.
*/
export function createOpenTerminalQueue() {
let tail: Promise<void> = Promise.resolve();
let depth = 0;
return {
enqueue(task: OpenTerminalTask): Promise<void> {
depth += 1;
let release: () => void;
const finished = new Promise<void>((resolve) => {
release = () => {
depth -= 1;
resolve();
};
});
const previous = tail;
tail = finished;
const run = (): Promise<void> => {
try {
const result = task();
if (isPromise(result)) {
return result.then(release, (error: unknown) => {
release();
throw error;
});
}
release();
return Promise.resolve();
} catch (error) {
release();
throw error;
}
};
if (depth === 1) return run();
return previous.then(run, run);
},
};
}
+3
View File
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
hostname: request.hostname,
savedPassword: request.savedPassword,
allowSavePassword: request.allowSavePassword !== false,
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
? { totpUri: request.totpUri }
: {}),
}]);
});
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
+48
View File
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
'ai.mimo.title': 'MiMo Code',
@@ -356,6 +364,14 @@ export const enAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
'ai.externalMcp.approvalAlert': 'Approval alerts',
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
'ai.externalMcp.approvalAlert.off': 'Off',
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
'ai.externalMcp.approvalAlert.always': 'Always',
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
@@ -368,6 +384,38 @@ export const enAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
'ai.externalMcp.http.enable': 'Enable HTTP',
'ai.externalMcp.http.port': 'Port',
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenAddress': 'Listen address',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Client address',
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerate',
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
'ai.externalMcp.http.envLine': 'Codex token env',
'ai.externalMcp.http.notListening': 'Not listening',
'ai.externalMcp.http.listening': 'Listening',
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission mode',
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
+7
View File
@@ -836,6 +836,10 @@ Highlight the focused split pane:
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
@@ -845,6 +849,9 @@ Highlight the focused split pane:
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Default proxy',
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
'settings.terminal.connection.defaultProxy.none': 'None',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
+4
View File
@@ -109,6 +109,8 @@ export const enTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.gpu': 'GPU Usage',
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
@@ -910,11 +912,13 @@ export const enTerminalMessages: Messages = {
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.verificationCode': 'Verification code',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.savePassword': 'Save password',
'keyboard.interactive.fillTotp': 'Fill verification code',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
+35
View File
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.quickConnectTo': 'Quick connect to {target}',
'qs.hosts': 'Hosts',
'qs.tabs': 'Tabs',
'qs.newWorkspace': 'New Workspace',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
@@ -876,6 +880,35 @@ export const enVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
'hostDetails.totp.title': 'Authenticator (TOTP)',
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
'hostDetails.totp.setup': 'Set up…',
'hostDetails.totp.edit': 'Edit',
'hostDetails.totp.modalTitle': 'Set up authenticator',
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
'hostDetails.totp.secret': 'Secret or otpauth URI',
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
'hostDetails.totp.importQr': 'Import QR image',
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
'hostDetails.totp.scanning': 'Scanning screens…',
'hostDetails.totp.importedAccount': 'Imported {account}',
'hostDetails.totp.preview': 'Current code',
'hostDetails.totp.countdown': '{seconds}s remaining',
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
'hostDetails.totp.clear': 'Remove authenticator',
'hostDetails.totp.removeTitle': 'Remove authenticator?',
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
@@ -949,6 +982,8 @@ export const enVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.direct': 'Direct (no proxy)',
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
+40
View File
@@ -347,6 +347,14 @@ export const esAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
'ai.externalMcp.approvalAlert.off': 'Desactivado',
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
'ai.externalMcp.approvalAlert.always': 'Siempre',
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
@@ -359,6 +367,38 @@ export const esAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
'ai.externalMcp.http.enable': 'Activar HTTP',
'ai.externalMcp.http.port': 'Puerto',
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerar',
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
'ai.externalMcp.http.notListening': 'Sin escucha',
'ai.externalMcp.http.listening': 'Escuchando',
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
+7
View File
@@ -751,6 +751,10 @@ Resalta el panel dividido enfocado:
'settings.terminal.section.connection': 'Conexión',
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
@@ -760,6 +764,9 @@ Resalta el panel dividido enfocado:
'settings.terminal.connection.x11Display': 'Pantalla X11',
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
+4
View File
@@ -109,6 +109,8 @@ export const esTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.gpu': 'Uso de GPU',
'terminal.serverStats.gpuDevices': 'Uso de cada GPU',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
@@ -872,11 +874,13 @@ export const esTerminalMessages: Messages = {
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
'keyboard.interactive.response': 'Respuesta',
'keyboard.interactive.verificationCode': 'Código de verificación',
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
'keyboard.interactive.submit': 'Enviar',
'keyboard.interactive.verifying': 'Verificando...',
'keyboard.interactive.savePassword': 'Guardar contraseña',
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Frase de contraseña de la clave SSH',
+35
View File
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Buscar hosts o pestañas',
'qs.quickConnectTo': 'Conexión rápida a {target}',
'qs.hosts': 'Hosts',
'qs.tabs': 'Pestañas',
'qs.newWorkspace': 'Nuevo espacio de trabajo',
'qs.jumpTo': 'Ir a',
'qs.localTerminal': 'Terminal local',
'qs.localShells': 'Shells locales',
@@ -866,6 +870,35 @@ export const esVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
'hostDetails.totp.title': 'Autenticador (TOTP)',
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
'hostDetails.totp.setup': 'Configurar…',
'hostDetails.totp.edit': 'Editar',
'hostDetails.totp.modalTitle': 'Configurar autenticador',
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
'hostDetails.totp.secret': 'Secreto o URI otpauth',
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
'hostDetails.totp.importQr': 'Importar imagen QR',
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
'hostDetails.totp.scanning': 'Escaneando pantallas…',
'hostDetails.totp.importedAccount': 'Importado: {account}',
'hostDetails.totp.preview': 'Código actual',
'hostDetails.totp.countdown': 'Quedan {seconds} s',
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
'hostDetails.totp.clear': 'Quitar autenticador',
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
'hostDetails.identity.suggestions': 'Identidades',
'hostDetails.identity.missing': 'Identidad no encontrada',
@@ -939,6 +972,8 @@ export const esVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxy.none': 'Ninguno',
'hostDetails.proxy.direct': 'Directo (sin proxy)',
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
'hostDetails.proxy.edit': 'Editar proxy',
'hostDetails.proxy.configure': 'Configurar proxy',
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'ai.externalMcp.approvalAlert',
'ai.externalMcp.approvalAlert.description',
'ai.externalMcp.approvalAlert.off',
'ai.externalMcp.approvalAlert.unfocused',
'ai.externalMcp.approvalAlert.always',
'ai.externalMcp.approvalAlert.notificationTitle',
'ai.externalMcp.approvalAlert.notificationBody',
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
] as const;
test('External MCP approval alerts are localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing approval alert labels`);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
}
});
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
const KEYS = [
"ai.externalMcp.http.title",
"ai.externalMcp.http.description",
"ai.externalMcp.http.enable",
"ai.externalMcp.http.port",
"ai.externalMcp.http.portHint",
"ai.externalMcp.http.allowCustomBind",
"ai.externalMcp.http.allowCustomBind.description",
"ai.externalMcp.http.listenAddress",
"ai.externalMcp.http.listenAddress.placeholder",
"ai.externalMcp.http.clientHost",
"ai.externalMcp.http.clientHost.description",
"ai.externalMcp.http.clientHost.placeholder",
"ai.externalMcp.http.clientHostRequired",
"ai.externalMcp.http.invalidClientHost",
"ai.externalMcp.http.warning",
"ai.externalMcp.http.url",
"ai.externalMcp.http.token",
"ai.externalMcp.http.regenerate",
"ai.externalMcp.http.regenerateHint",
"ai.externalMcp.http.wslHint",
"ai.externalMcp.http.codexSnippet",
"ai.externalMcp.http.claudeCommand",
"ai.externalMcp.http.genericSnippet",
"ai.externalMcp.http.envLine",
"ai.externalMcp.http.notListening",
"ai.externalMcp.http.listening",
"ai.externalMcp.http.portInUse",
"ai.externalMcp.http.requiresExternalMcp",
"ai.externalMcp.http.invalidPort",
"ai.externalMcp.http.invalidAddress",
"ai.externalMcp.http.listenFailed",
"ai.externalMcp.http.legacySseNote",
] as const;
test("External MCP HTTP strings exist in every locale", () => {
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
}
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
});
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from './en';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const KEYS = [
'ai.opencode.v2.startupTimeout',
'ai.opencode.v2.exited',
'ai.opencode.v2.unauthorized',
'ai.opencode.v2.unavailable',
'ai.opencode.v2.sessionFailed',
'ai.opencode.v2.empty',
'ai.opencode.v2.contextSkipped',
'ai.opencode.v2.mcpSkipped',
];
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
for (const key of KEYS) {
assert.equal(typeof en[key], 'string', `en missing ${key}`);
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
assert.notEqual(en[key], zhCN[key]);
assert.notEqual(en[key], zhTW[key]);
assert.notEqual(zhCN[key], zhTW[key]);
}
});
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
const text = KEYS.map((key) => zhTW[key]).join('\n');
assert.match(text, /終端機/);
assert.match(text, /連線/);
assert.match(text, /設定/);
assert.match(text, /傳回/);
assert.equal(text.includes('终端'), false);
assert.equal(text.includes('连接'), false);
assert.equal(text.includes('设置'), false);
assert.equal(text.includes('返回'), false);
});
+40
View File
@@ -303,6 +303,14 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'мин',
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
'ai.externalMcp.approvalAlert.off': 'Выкл.',
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
'ai.externalMcp.approvalAlert.always': 'Всегда',
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
@@ -315,6 +323,38 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
'ai.externalMcp.http.enable': 'Включить HTTP',
'ai.externalMcp.http.port': 'Порт',
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Адрес клиента',
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Создать заново',
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
'ai.externalMcp.http.notListening': 'Не слушает',
'ai.externalMcp.http.listening': 'Слушает',
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
+7
View File
@@ -791,6 +791,10 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
@@ -798,6 +802,9 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display': 'Дисплей X11',
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
'settings.terminal.connection.defaultProxy.none': 'Нет',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
+4
View File
@@ -130,6 +130,8 @@ export const ruTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.gpu': 'Использование GPU',
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
@@ -917,11 +919,13 @@ export const ruTerminalMessages: Messages = {
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
'keyboard.interactive.response': 'Ответ',
'keyboard.interactive.verificationCode': 'Код подтверждения',
'keyboard.interactive.enterCode': 'Введите код подтверждения',
'keyboard.interactive.enterResponse': 'Введите ответ',
'keyboard.interactive.submit': 'Отправить',
'keyboard.interactive.verifying': 'Проверка...',
'keyboard.interactive.savePassword': 'Сохранить пароль',
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Парольная фраза SSH-ключа',
+35
View File
@@ -710,6 +710,10 @@ export const ruVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.quickConnectTo': 'Быстрое подключение к {target}',
'qs.hosts': 'Хосты',
'qs.tabs': 'Вкладки',
'qs.newWorkspace': 'Новая рабочая область',
'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки',
@@ -914,6 +918,35 @@ export const ruVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
'hostDetails.totp.setup': 'Настроить…',
'hostDetails.totp.edit': 'Изменить',
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
'hostDetails.totp.secret': 'Секрет или URI otpauth',
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
'hostDetails.totp.importQr': 'Импорт изображения QR',
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
'hostDetails.totp.scanning': 'Сканирование экранов…',
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
'hostDetails.totp.preview': 'Текущий код',
'hostDetails.totp.countdown': 'Осталось {seconds} с',
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
'hostDetails.totp.clear': 'Удалить аутентификатор',
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
'hostDetails.identity.suggestions': 'Идентификаторы',
'hostDetails.identity.missing': 'Идентификатор не найден',
@@ -987,6 +1020,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'Нет',
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
'hostDetails.proxy.edit': 'Редактировать прокси',
'hostDetails.proxy.configure': 'Настроить прокси',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { enTerminalMessages } from "./en/terminal";
import { esTerminalMessages } from "./es/terminal";
import { ruTerminalMessages } from "./ru/terminal";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTWVaultMessages } from "./zh-TW/vault";
test("per-GPU stats strings exist in every locale", () => {
const labels = [
[enTerminalMessages, "GPU Device Usage", "GPU {{index}}"],
[esTerminalMessages, "Uso de cada GPU", "GPU {{index}}"],
[ruTerminalMessages, "Использование видеокарт", "GPU {{index}}"],
[zhCNVaultMessages, "GPU 设备使用率", "GPU {{index}}"],
[zhTWVaultMessages, "GPU 裝置使用率", "GPU {{index}}"],
] as const;
for (const [messages, devices, indexLabel] of labels) {
assert.equal(messages["terminal.serverStats.gpuDevices"], devices);
assert.equal(messages["terminal.serverStats.gpuIndex"], indexLabel);
}
});
@@ -47,6 +47,56 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include temporary-connection legacy algorithm labels", () => {
const keys = [
"settings.terminal.connection.temporaryLegacyAlgorithms",
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
];
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
for (const locale of locales) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
}
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
});
test("localized settings include temporary-connection network device mode labels", () => {
const keys = [
"settings.terminal.connection.temporaryNetworkDeviceMode",
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
];
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
for (const locale of locales) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
}
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
});
test("localized settings include the global default proxy labels", () => {
const keys = [
"settings.terminal.connection.defaultProxy",
"settings.terminal.connection.defaultProxy.desc",
"settings.terminal.connection.defaultProxy.none",
"hostDetails.proxy.direct",
"hostDetails.proxy.directHint",
];
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
for (const locale of locales) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing global default proxy labels`);
}
});
test("localized settings include network proxy labels", () => {
const keys = [
"settings.system.networkProxy.title",
+48
View File
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
@@ -356,6 +364,14 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分钟',
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
'ai.externalMcp.approvalAlert': '审批提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
'ai.externalMcp.approvalAlert.off': '关闭',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
'ai.externalMcp.approvalAlert.always': '始终',
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
@@ -368,6 +384,38 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
'ai.externalMcp.http.enable': '启用 HTTP',
'ai.externalMcp.http.port': '端口',
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '监听地址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '客户端地址',
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新生成',
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
'ai.externalMcp.http.notListening': '未监听',
'ai.externalMcp.http.listening': '监听中',
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
+4
View File
@@ -1139,6 +1139,10 @@ Vaults 标签保持扁平,不使用这块背景。
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.quickConnectTo': '快速连接到 {target}',
'qs.hosts': '主机',
'qs.tabs': '标签页',
'qs.newWorkspace': '新建工作区',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
@@ -454,6 +454,13 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
'settings.terminal.connection.defaultProxy': '默认代理',
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
'settings.terminal.connection.defaultProxy.none': '无',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
@@ -996,11 +1003,13 @@ export const zhCNTerminalMessages: Messages = {
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应',
'keyboard.interactive.verificationCode': '验证码',
'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.savePassword': '保存密码',
'keyboard.interactive.fillTotp': '填入验证码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
+33
View File
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
'hostDetails.auth.mfaFirst': '优先交互式认证',
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
'hostDetails.totp.title': '验证器 (TOTP)',
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
'hostDetails.totp.setup': '设置…',
'hostDetails.totp.edit': '编辑',
'hostDetails.totp.modalTitle': '设置验证器',
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
'hostDetails.totp.secret': '密钥或 otpauth URI',
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
'hostDetails.totp.importQr': '导入二维码图片',
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
'hostDetails.totp.scanning': '正在扫描屏幕…',
'hostDetails.totp.importedAccount': '已导入 {account}',
'hostDetails.totp.preview': '当前验证码',
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
'hostDetails.totp.promptPattern': '自定义提示正则',
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
'hostDetails.totp.clear': '移除验证器',
'hostDetails.totp.removeTitle': '移除验证器?',
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
@@ -363,6 +392,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.direct': '直连(不使用代理)',
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
'hostDetails.envVars': '环境变量',
@@ -532,6 +563,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
+48
View File
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重設',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
@@ -346,6 +354,14 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分鐘',
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
'ai.externalMcp.approvalAlert': '核准提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
'ai.externalMcp.approvalAlert.off': '關閉',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
'ai.externalMcp.approvalAlert.always': '一律',
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
@@ -358,6 +374,38 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
'ai.externalMcp.http.enable': '啟用 HTTP',
'ai.externalMcp.http.port': '連接埠',
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '監聽位址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '用戶端位址',
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新產生',
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
'ai.externalMcp.http.notListening': '未監聽',
'ai.externalMcp.http.listening': '監聽中',
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
+4
View File
@@ -1141,6 +1141,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
// Quick Switcher
'qs.search.placeholder': '搜尋主機或分頁',
'qs.quickConnectTo': '快速連線到 {target}',
'qs.hosts': '主機',
'qs.tabs': '分頁',
'qs.newWorkspace': '新增工作區',
'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell',
+10 -1
View File
@@ -454,6 +454,13 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
'settings.terminal.connection.defaultProxy': '預設代理',
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
'settings.terminal.connection.defaultProxy.none': '無',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
@@ -996,11 +1003,13 @@ export const zhTWTerminalMessages: Messages = {
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
'keyboard.interactive.response': '回應',
'keyboard.interactive.verificationCode': '驗證碼',
'keyboard.interactive.enterCode': '輸入驗證碼',
'keyboard.interactive.enterResponse': '輸入回應',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.submit': '送出',
'keyboard.interactive.verifying': '驗證中...',
'keyboard.interactive.savePassword': '儲存密碼',
'keyboard.interactive.fillTotp': '填入驗證碼',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 金鑰密碼',
+33
View File
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
'hostDetails.auth.mfaFirst': '優先互動式驗證',
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
'hostDetails.totp.title': '驗證器 (TOTP)',
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
'hostDetails.totp.setup': '設定…',
'hostDetails.totp.edit': '編輯',
'hostDetails.totp.modalTitle': '設定驗證器',
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
'hostDetails.totp.secret': '金鑰或 otpauth URI',
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
'hostDetails.totp.scanning': '正在掃描螢幕…',
'hostDetails.totp.importedAccount': '已匯入 {account}',
'hostDetails.totp.preview': '目前驗證碼',
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
'hostDetails.totp.promptPattern': '自訂提示規則',
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
'hostDetails.totp.clear': '移除驗證器',
'hostDetails.totp.removeTitle': '移除驗證器?',
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
'hostDetails.identity.suggestions': '身分',
'hostDetails.identity.missing': '身分不存在',
@@ -363,6 +392,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': '設定代理主機',
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
'hostDetails.proxy.none': '無',
'hostDetails.proxy.direct': '直連(不使用代理)',
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
'hostDetails.proxy.edit': '編輯代理',
'hostDetails.proxy.configure': '設定代理',
'hostDetails.envVars': '環境變數',
@@ -532,6 +563,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
openQuickConnectFromInput,
registerQuickConnectOpener,
type QuickConnectOpenRequest,
} from "./quickConnectBridge.ts";
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
const seen: QuickConnectOpenRequest[] = [];
const unregister = registerQuickConnectOpener((request) => {
seen.push(request);
});
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
assert.deepEqual(seen, [{
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
warnings: [],
}]);
unregister();
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
});
test("openQuickConnectFromInput ignores text that is not an address", () => {
const seen: QuickConnectOpenRequest[] = [];
const unregister = registerQuickConnectOpener((request) => {
seen.push(request);
});
assert.equal(openQuickConnectFromInput("core switch"), false);
assert.deepEqual(seen, []);
unregister();
});
+28
View File
@@ -0,0 +1,28 @@
import {
parseQuickConnectInputWithWarnings,
type QuickConnectTarget,
} from "../domain/quickConnect";
export interface QuickConnectOpenRequest {
target: QuickConnectTarget;
warnings: string[];
}
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
let opener: QuickConnectOpener | null = null;
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
opener = next;
return () => {
if (opener === next) opener = null;
};
}
export function openQuickConnectFromInput(input: string): boolean {
const parsed = parseQuickConnectInputWithWarnings(input);
if (!parsed.target || !opener) return false;
opener({ target: parsed.target, warnings: parsed.warnings });
return true;
}
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
return `${values?.tool} on ${values?.host} is waiting for approval`;
}
return key;
};
test('approval alerts stay quiet when the preference is off', () => {
const calls: unknown[] = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
dedupeKey: 'mcp_approval_1',
mode: 'off',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, false);
assert.deepEqual(calls, []);
});
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
hostLabel: 'bastion',
dedupeKey: 'mcp_approval_2',
mode: 'unfocused',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, true);
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'sftp.write on bastion is waiting for approval',
dedupeKey: 'mcp_approval_2',
whenUnfocused: true,
}]);
});
test('approval alerts show the MCP tool name for an internal rpc id', () => {
const calls: Array<{ body: string }> = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'netcatty/exec',
hostLabel: '127.0.0.1',
dedupeKey: 'mcp_approval_exec',
mode: 'always',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, true);
assert.equal(calls.length, 1);
assert.match(calls[0].body, /terminal_execute/);
assert.equal(calls[0].body, 'terminal_execute on 127.0.0.1 is waiting for approval');
});
test('approval alerts keep an id that has no MCP tool name', () => {
const calls: Array<{ body: string }> = [];
presentExternalMcpApprovalNotification({
toolName: 'netcatty/not-a-tool',
dedupeKey: 'mcp_approval_unknown',
mode: 'unfocused',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(calls[0].body, 'netcatty/not-a-tool is waiting for approval');
});
test('always approval alerts notify even while a window may be focused', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
presentExternalMcpApprovalNotification({
toolName: 'terminal.execute',
dedupeKey: 'mcp_approval_3',
mode: 'always',
t,
notify: (payload) => calls.push(payload),
});
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'terminal.execute is waiting for approval',
dedupeKey: 'mcp_approval_3',
whenUnfocused: false,
}]);
});
@@ -0,0 +1,91 @@
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
import cattyToolSpecs from '../../infrastructure/ai/harness/generated/cattyToolSpecs.json';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
readExternalMcpApprovalAlertMode,
type ExternalMcpApprovalAlertMode,
} from './useExternalMcpToggleState';
type CatalogToolSpec = {
toolName?: string;
rpcMethod?: string | null;
};
const mcpToolNameByRpcMethod = new Map<string, string>();
for (const spec of cattyToolSpecs as CatalogToolSpec[]) {
if (!spec.rpcMethod || !spec.toolName || mcpToolNameByRpcMethod.has(spec.rpcMethod)) continue;
mcpToolNameByRpcMethod.set(spec.rpcMethod, spec.toolName);
}
/**
* Name shown on the External MCP approval card.
* Catalog MCP tool names win over internal RPC ids such as `netcatty/exec`.
* An id with no catalog tool name is shown as-is.
*/
export function externalMcpApprovalToolLabel(toolName: string | undefined): string {
const raw = toolName?.trim() || '';
if (!raw) return '';
return mcpToolNameByRpcMethod.get(raw) || raw;
}
export type ExternalMcpApprovalNotification = {
title: string;
body: string;
dedupeKey: string;
whenUnfocused: boolean;
};
type Translate = (
key: string,
values?: Record<string, string | number | boolean | null | undefined>,
) => string;
/**
* Build the system notification for one External MCP approval.
* Returns null when the preference is off. Focus gating stays in the main
* process so every Netcatty window is considered, not only the sender.
*/
export function presentExternalMcpApprovalNotification(options: {
toolName: string;
hostLabel?: string;
dedupeKey: string;
mode: ExternalMcpApprovalAlertMode;
t: Translate;
notify: (payload: ExternalMcpApprovalNotification) => void;
}): boolean {
if (options.mode === 'off') return false;
const tool = externalMcpApprovalToolLabel(options.toolName);
const host = options.hostLabel?.trim() || '';
const body = host
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
options.notify({
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
body,
dedupeKey: options.dedupeKey,
whenUnfocused: options.mode === 'unfocused',
});
return true;
}
export function publishExternalMcpApprovalAlertMode(): void {
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
mode: readExternalMcpApprovalAlertMode(),
});
}
export function notifyExternalMcpApproval(
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
t: Translate,
): boolean {
return presentExternalMcpApprovalNotification({
toolName: request.toolName,
hostLabel: request.target?.label || request.target?.hostname || '',
dedupeKey: request.toolCallId,
mode: readExternalMcpApprovalAlertMode(),
t,
notify: (payload) => {
void netcattyBridge.get()?.showSystemNotification?.(payload);
},
});
}
+6 -5
View File
@@ -13,10 +13,11 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
export interface CaptureInheritedCwdOptions {
/**
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
* cwd map rather than the session object. This is the freshest value and the
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
* never mirrored onto `TerminalSession.lastCwd`).
* The session's live tracked cwd (OSC 7 or the post-command cwd probe),
* sourced from the terminal-state cwd map rather than the session object.
* This is the freshest value and the one that reflects `cd`s in a running
* local terminal. The live value stays off `TerminalSession.lastCwd` until
* session restore persists it.
*/
liveCwd?: string;
/**
@@ -35,7 +36,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
/**
* Resolve the working directory a clone/split should inherit from its source.
*
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed)
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
* against a short timeout so a slow/wedged connection can't block tab creation,
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when
+13
View File
@@ -51,6 +51,7 @@ import {
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
@@ -58,6 +59,10 @@ import {
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import {
normalizeGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
} from '../../domain/globalDefaultProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
isValidUiFontId,
@@ -98,6 +103,7 @@ interface UseSettingsIpcSyncParams {
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -146,6 +152,7 @@ export function useSettingsIpcSync({
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -292,6 +299,11 @@ export function useSettingsIpcSync({
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
));
}
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
const next = normalizeGlobalDefaultProxyProfileId(value);
publishActiveGlobalDefaultProxyProfileId(next);
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
@@ -354,6 +366,7 @@ export function useSettingsIpcSync({
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
@@ -10,12 +10,14 @@ import {
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
@@ -214,3 +216,76 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\n",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\e\\r",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
shiftEnterNewlineText: "\\r",
scrollback: 1000,
});
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
const store = installMemoryLocalStorage();
try {
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+28 -1
View File
@@ -1,8 +1,15 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
type HotkeyScheme,
type SessionLogFormat,
type TerminalSettings,
} from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
@@ -91,6 +98,26 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
/**
* One-time migration for the Shift+Enter default flip (#3770). Builds before
* that change materialized `\\n` into saved settings, so upgraders kept a
* bare newline and TUI CLIs still submitted. Rewrite only that old default
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
* is left alone. Custom text is never rewritten. Sync and import do not
* read this marker; an old `\\n` from another device stays as stored.
*/
export function migrateMaterializedShiftEnterNewlineDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
...stored,
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
});
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { connectionTotpFields } from "../../../domain/totpConfig";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import {
findIncompleteProxyIdentityId,
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
...connectionTotpFields(jumpHost),
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase,
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
requiresMfa: !!host.requiresMfa,
port: host.port || 22,
password,
...connectionTotpFields(host),
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
passphrase: keyAuth.passphrase,
@@ -712,3 +712,127 @@ test("owned cancelled directory settles recoverable children without dropping co
await rm(root, { recursive: true, force: true });
}
});
for (const liveSize of [37, 3, 0]) {
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
const pane = makePane("right");
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
const started: StartOptions[] = [];
const restore = installGlobals({
statLocal: async (path: string) => path.endsWith("edited.txt")
? { type: "file", size: liveSize, lastModified: 1758000002000 }
: null,
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
pauseTransfer: async () => ({ success: false }),
resumeTransfer: async () => ({ success: false }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: `local-edited-${liveSize}`,
getActivePane: () => pane,
getPaneByConnectionId: () => pane,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
});
assert.equal(ops!.conflicts.length, 1);
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
await act(async () => {
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
await new Promise((resolve) => setTimeout(resolve, 300));
});
assert.equal(started.length, 1);
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
const pane = makePane("right");
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
const started: StartOptions[] = [];
const restore = installGlobals({
// Target conflict stat: missing path means the copy has no conflict.
lstatLocal: async () => null,
statLocal: async (path: string) => {
if (!path.endsWith("late.txt")) return null;
// Let the stream finish before the stat resolves — fired-and-forgotten
// discovery used to patch the terminal task with this stale total.
await new Promise((resolve) => setTimeout(resolve, 200));
return { type: "file", size: 9_999, lastModified: 1758000002000 };
},
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: 15,
totalBytes: 15,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false }),
resumeTransfer: async () => ({ success: false }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "local-late-stat",
getActivePane: () => pane,
getPaneByConnectionId: () => pane,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
});
// Give the no longer pending deferred stat time to land if it leaked.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
(candidate) => candidate.originalFileName === "late.txt",
);
assert.ok(row);
assert.equal(row.status, "completed");
assert.equal(
row.transferredBytes,
row.totalBytes,
"a deferred stat must not desync the completed row's total from its transferred bytes",
);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
+19 -16
View File
@@ -811,8 +811,8 @@ export const useSftpTransfers = ({
})();
// For single files: start size discovery without blocking the conflict
// check, but keep the promise so an actual conflict can await fresh
// size metadata before the dialog is shown.
// check, but always settle the promise before any dialog or transfer
// launch (bounded per-task; the task is already visible/cancellable).
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
// Only await conflict check (fast single stat call)
@@ -823,19 +823,23 @@ export const useSftpTransfers = ({
// null means the plan size is still unknown. A verified 0-byte stat is 0,
// not null, so an empty file does not pick up a stale listing size.
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
if (conflict && sizeDiscoveryPromise) {
if (sizeDiscoveryPromise) {
// The task's totalBytes may still be 0 here because discovery ran
// deferred and updateTask never mutates the local task copy. Await
// discovery (bounded per-task; the task is already visible/cancellable)
// so the overwrite dialog shows the real source size, not 0 (#3559).
// discovery even without a conflict so its statLocal updateTask cannot
// resolve after the transfer finished and overwrite the bridge's
// authoritative total with a stale late size (the conflict dialog also
// needs it: #3559).
const discovered = await sizeDiscoveryPromise;
// Cancel during this stat must win before an apply-to-all Replace
// deletes an existing symlink.
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
if (discovered && discovered.size >= 0) {
conflict.newSize = discovered.size;
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.plan) verifiedPlanBytes = discovered.size;
if (conflict) {
conflict.newSize = discovered.size;
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.plan) verifiedPlanBytes = discovered.size;
}
}
}
@@ -1267,25 +1271,24 @@ export const useSftpTransfers = ({
// Prefer the fresh re-stat for remote files: a cached listing size can
// be stale and undersized, which would silently truncate downloads
// (#3559). Remote files without a live stat use plan 0 so the bridge
// measures the live size; local sources keep the pane metadata.
// measures the live size. Local files also start unknown so deferred
// discovery reads their current size after edits, not the cached listing.
const fileEntry = fileEntryMap?.get(file.name);
const freshMetadata = freshRemoteMetadata.get(file.name);
const fileSize = file.isDirectory
? 0
: freshMetadata
? freshMetadata.size
: sourcePane.connection!.isLocal
? (fileEntry?.size ?? 0)
: 0;
// Only keep an mtime that is paired with trusted metadata (live re-stat,
// local source size, or directory totals). A remote file whose re-stat
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
: 0;
// Only keep an mtime paired with a live re-stat or directory totals.
// A file without a live stat must stay fully unknown
// (size 0 AND mtime 0 in the plan):
// discoverTransferSize and the conflict check both treat a nonzero
// sourceLastModified as proof the size is already known, so a stale
// mtime would suppress the deferred source re-stat. The overwrite
// dialog still gets the pane listing's cached entry via
// discoverTransferSize's cachedListingStatForDialog fallback.
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
const sourceLastModified = !file.isDirectory && !freshMetadata
? 0
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
@@ -3,6 +3,17 @@ import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
readExternalMcpHttpSettings,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
} from "./useExternalMcpToggleState.ts";
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -11,9 +22,11 @@ import {
getExternalMcpStartupReadyWaiterCountForTests,
isExternalMcpStartupReady,
markExternalMcpStartupReady,
normalizeExternalMcpApprovalAlertMode,
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes,
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen,
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
@@ -25,6 +38,7 @@ import {
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
} from './useExternalMcpToggleState.ts';
@@ -69,6 +83,22 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
});
it('stores the approval alert preference and defaults to unfocused', () => {
const restore = installMemoryLocalStorage();
try {
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
} finally {
restore();
}
});
for (const savedMinutes of [null, 30]) {
@@ -395,3 +425,30 @@ describe('syncExternalMcpStartupState generation guard', () => {
}
});
});
describe("external MCP HTTP settings", () => {
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
const restore = installMemoryLocalStorage();
try {
assert.deepEqual(readExternalMcpHttpSettings(), {
enabled: false,
port: 47391,
allowCustomBind: false,
listenAddress: "0.0.0.0",
clientHost: "",
});
assert.equal(readExternalMcpHttpEnabled(), false);
writeExternalMcpHttpEnabled(true);
assert.equal(writeExternalMcpHttpPort(80), 47391);
assert.equal(writeExternalMcpHttpPort(48080), 48080);
writeExternalMcpHttpAllowCustomBind(true);
writeExternalMcpHttpListenAddress("172.22.0.1");
assert.equal(readExternalMcpHttpEnabled(), true);
assert.equal(readExternalMcpHttpPort(), 48080);
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
} finally {
restore();
}
});
});
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import {
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
@@ -9,11 +15,15 @@ import {
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export type ExternalMcpMode = 'temporary' | 'persistent';
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
@@ -100,10 +110,23 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
return externalMcpStartupReadyWaitPromise;
}
export type ExternalMcpHttpSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
};
type ExternalMcpBridge = {
@@ -183,6 +206,26 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
}
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
return value === 'off' || value === 'always' || value === 'unfocused'
? value
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
}
/** System notification preference for confirm-mode External MCP approvals. */
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
return normalizeExternalMcpApprovalAlertMode(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
);
}
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
return normalized;
}
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
export function readExternalMcpSilentSessions(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
@@ -217,6 +260,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
return normalized;
}
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
export function normalizeExternalMcpHttpPort(value: number | null): number {
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
}
return value;
}
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
const address = (value ?? '').trim();
if (!address) return '0.0.0.0';
return address;
}
export function readExternalMcpHttpEnabled(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
}
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
return enabled;
}
export function readExternalMcpHttpPort(): number {
return normalizeExternalMcpHttpPort(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
);
}
export function writeExternalMcpHttpPort(port: number): number {
const normalized = normalizeExternalMcpHttpPort(port);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
return normalized;
}
export function readExternalMcpHttpAllowCustomBind(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
}
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
return allowCustomBind;
}
export function readExternalMcpHttpListenAddress(): string {
return normalizeExternalMcpHttpListenAddress(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
);
}
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
return normalized;
}
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
return (value ?? '').trim();
}
export function readExternalMcpHttpClientHost(): string {
return normalizeExternalMcpHttpClientHost(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
);
}
export function writeExternalMcpHttpClientHost(clientHost: string): string {
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
return normalized;
}
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
return {
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
};
}
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
ExternalMcpConfig,
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
> {
return {
httpEnabled: settings.enabled,
httpPort: settings.port,
httpAllowCustomBind: settings.allowCustomBind,
httpListenAddress: settings.listenAddress,
httpClientHost: settings.clientHost,
};
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
@@ -239,11 +385,19 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
allowCustomBind: false,
listenAddress: '0.0.0.0',
clientHost: '',
},
}: {
enabled: boolean;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
@@ -252,6 +406,7 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
...httpConfigFromSettings(http),
},
runtimeEnabled,
storedEnabled,
@@ -265,14 +420,17 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
});
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
const http = readExternalMcpHttpSettings();
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
...httpConfigFromSettings(http),
});
}
@@ -0,0 +1,14 @@
import { useSyncExternalStore } from 'react';
import {
getActiveGlobalDefaultProxyProfileId,
subscribeActiveGlobalDefaultProxyProfileId,
} from '../../domain/globalDefaultProxy';
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
return useSyncExternalStore(
subscribeActiveGlobalDefaultProxyProfileId,
getActiveGlobalDefaultProxyProfileId,
getActiveGlobalDefaultProxyProfileId,
);
};
+11
View File
@@ -24,6 +24,14 @@ export interface ProcessInfo {
command: string;
}
export interface GpuDeviceStats {
index: number;
name: string;
utilization: number | null; // percent, null when nvidia-smi reports N/A
memoryUsed: number | null; // MiB
memoryTotal: number | null; // MiB
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
@@ -37,6 +45,7 @@ export interface ServerStats {
gpuName: string | null; // First GPU name (tooltip)
gpuMemUsed: number | null; // Summed VRAM used in MB
gpuMemTotal: number | null; // Summed VRAM total in MB
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
@@ -116,6 +125,7 @@ function createEmptyServerStats(): ServerStats {
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
gpus: [],
memTotal: null,
memUsed: null,
memFree: null,
@@ -218,6 +228,7 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
gpuName: stats.gpuName ?? null,
gpuMemUsed: stats.gpuMemUsed ?? null,
gpuMemTotal: stats.gpuMemTotal ?? null,
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import {
clearActiveGlobalDefaultProxyProfile,
getActiveGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
} from "../../domain/globalDefaultProxy.ts";
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
import { useSettingsState } from "./useSettingsState.ts";
type Notification = { key: string; value: unknown };
const withSettingsDom = async (
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
) => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
const globalAny = globalThis as Record<string, unknown>;
const saved = {
window: globalAny.window,
document: globalAny.document,
localStorage: globalAny.localStorage,
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
};
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const notifications: Notification[] = [];
dom.window.netcatty = {
notifySettingsChanged: (payload: Notification) => {
notifications.push(payload);
},
} as Window["netcatty"];
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
const container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
const root = createRoot(container);
try {
await run({ dom, root, notifications });
} finally {
await act(async () => {
root.unmount();
});
publishActiveGlobalDefaultProxyProfileId(undefined);
container.remove();
dom.window.close();
for (const key of ["window", "document", "localStorage"] as const) {
if (saved[key] === undefined) delete globalAny[key];
else {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value: saved[key],
});
}
}
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: saved.actEnv,
});
}
};
const proxyNotifications = (notifications: Notification[]) => (
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
);
test("closing a second settings instance still persists a cleared global default proxy", async () => {
await withSettingsDom(async ({ dom, root, notifications }) => {
let showSecond = true;
function SettingsProbe() {
useSettingsState({ enableSystemEffects: false });
return null;
}
function Harness() {
return React.createElement(
React.Fragment,
null,
React.createElement(SettingsProbe),
showSecond ? React.createElement(SettingsProbe) : null,
);
}
await act(async () => {
root.render(React.createElement(Harness));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
showSecond = false;
await act(async () => {
root.render(React.createElement(Harness));
});
notifications.length = 0;
await act(async () => {
clearActiveGlobalDefaultProxyProfile();
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
assert.deepEqual(proxyNotifications(notifications), [
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
]);
});
});
test("a peer session window follows global default proxy storage without settings sync", async () => {
await withSettingsDom(async ({ dom, root, notifications }) => {
function PeerSettings() {
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
return null;
}
await act(async () => {
root.render(React.createElement(PeerSettings));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
notifications.length = 0;
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
await act(async () => {
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
newValue: "profile-2",
storageArea: dom.window.localStorage,
}));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
assert.deepEqual(proxyNotifications(notifications), []);
});
});
+55 -1
View File
@@ -70,6 +70,7 @@ import {
STORAGE_KEY_SHOW_TRAY_ICON,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
@@ -104,6 +105,11 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
normalizeGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
registerGlobalDefaultProxyProfileSetter,
} from '../../domain/globalDefaultProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
resolveSftpTransferConcurrency,
@@ -151,6 +157,7 @@ import {
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -360,6 +367,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
migrateMaterializedShiftEnterNewlineDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
@@ -574,6 +582,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
});
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
const next = normalizeGlobalDefaultProxyProfileId(
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
);
publishActiveGlobalDefaultProxyProfileId(next);
return next;
});
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
setHttpNetworkProxyState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -796,6 +811,37 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}
}, [enableSettingsSync]);
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
const next = normalizeGlobalDefaultProxyProfileId(value);
setGlobalDefaultProxyProfileIdState(next);
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
publishActiveGlobalDefaultProxyProfileId(next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
}, [notifySettingsChanged]);
useLayoutEffect(() => {
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
}, [globalDefaultProxyProfileId]);
useEffect(
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
[setGlobalDefaultProxyProfileId],
);
// Peer session windows set enableSettingsSync false, which skips IPC sync.
// This setting still has to follow localStorage or an already-open session
// window keeps the proxy id it read at mount.
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
publishActiveGlobalDefaultProxyProfileId(next);
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
@@ -1079,6 +1125,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
),
);
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
);
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
// Custom terminal themes
customThemeStore.loadFromStorage();
@@ -1227,6 +1278,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -2211,6 +2263,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchSupported,
httpNetworkProxy,
setHttpNetworkProxy,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
@@ -2233,7 +2287,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+24
View File
@@ -454,6 +454,30 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
});
test("buildSyncPayload round-trips the global default proxy profile", async () => {
localStorage.clear();
const absent = buildSyncPayload(vault([]));
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
const selected = buildSyncPayload(vault([]));
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
const cleared = buildSyncPayload(vault([]));
assert.equal(cleared.settings?.defaultProxyProfileId, "");
localStorage.clear();
await applySyncPayload(selected, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
await applySyncPayload(cleared, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
));
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
+13
View File
@@ -33,6 +33,7 @@ import {
type SyncPayload,
} from '../domain/sync';
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
import {
@@ -112,6 +113,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -345,6 +347,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
@@ -592,6 +595,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
}
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
if (storedDefaultProxyProfileId !== null) {
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
}
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
@@ -906,6 +913,12 @@ async function applySyncableSettings(
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
if (typeof settings.defaultProxyProfileId === 'string') {
localStorageAdapter.writeString(
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
);
}
const ai = settings.ai;
if (ai) {
+5
View File
@@ -15,6 +15,11 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
assert.equal(hasGroupSshFields({}), false);
});
test("GroupDetailsPanel keeps the SSH section when X11 forwarding is set", () => {
assert.equal(hasGroupSshFields({ x11Forwarding: true }), true);
assert.equal(hasGroupSshFields({ x11Forwarding: false }), true);
});
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
+8 -1
View File
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
c.agentForwarding !== undefined || c.x11Forwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
@@ -262,6 +262,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
delete next.identityFilePaths;
delete next.deviceType;
delete next.agentForwarding;
delete next.x11Forwarding;
delete next.startupCommand;
delete next.startupCommandRunMode;
delete next.legacyAlgorithms;
@@ -499,6 +500,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
}, [groupConfigs, parentGroup]);
const inheritedX11Forwarding = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
}, [groupConfigs, parentGroup]);
const inheritedStartupCommandRunMode = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return "paste";
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
@@ -568,6 +573,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
@@ -778,6 +784,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
setNewKeyFilePath={setNewKeyFilePath}
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
inheritedX11Forwarding={inheritedX11Forwarding}
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
+11
View File
@@ -47,6 +47,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
setNewKeyFilePath,
inheritedLegacyAlgorithms,
inheritedSkipEcdsaHostKey,
inheritedX11Forwarding,
inheritedStartupCommandRunMode,
showAlgorithmOverrides,
setShowAlgorithmOverrides,
@@ -395,6 +396,16 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!(form.x11Forwarding ?? inheritedX11Forwarding)}
onToggle={() => update(
"x11Forwarding",
!(form.x11Forwarding ?? inheritedX11Forwarding),
)}
/>
{/* Startup Command — Textarea so multi-line sequences are typeable
here just like on the per-host details panel (#1083 follow-up). */}
<Textarea
@@ -30,6 +30,17 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
assert.match(source, /resolveSshAgentToggleUpdate/);
});
test('X11 forwarding display follows the effective host after group defaults', () => {
assert.match(source, /applyGroupDefaults\(form, effectiveGroupDefaults\)/);
assert.match(source, /const showX11Forwarding = \(!effectiveConnectionHost\.protocol \|\| effectiveConnectionHost\.protocol === "ssh"\)/);
assert.match(source, /!effectiveConnectionHost\.moshEnabled/);
assert.match(source, /!effectiveConnectionHost\.etEnabled/);
assert.match(source, /const effectiveX11Forwarding = !!effectiveConnectionHost\.x11Forwarding/);
assert.match(source, /\{showX11Forwarding && \(/);
assert.match(source, /enabled=\{effectiveX11Forwarding\}/);
assert.doesNotMatch(source, /effectiveGroupDefaults\?\.x11Forwarding/);
});
test('login and forwarding warnings use independent agent status', () => {
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
+39 -4
View File
@@ -1,4 +1,5 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import { applyGroupDefaults } from "../domain/groupConfig";
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -63,6 +64,15 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
? form.useSshAgent !== false
: effectiveAuthMethod === "key" && form.useSshAgent === true;
// Match the connection: inherited X11 is dropped when the effective transport
// is Mosh, Eternal Terminal, or anything other than plain SSH.
const effectiveConnectionHost = effectiveGroupDefaults
? applyGroupDefaults(form, effectiveGroupDefaults)
: form;
const showX11Forwarding = (!effectiveConnectionHost.protocol || effectiveConnectionHost.protocol === "ssh")
&& !effectiveConnectionHost.moshEnabled
&& !effectiveConnectionHost.etEnabled;
const effectiveX11Forwarding = !!effectiveConnectionHost.x11Forwarding;
return (
<>
@@ -337,7 +347,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
{showX11Forwarding && (
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")}
@@ -345,8 +355,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!form.x11Forwarding}
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
enabled={effectiveX11Forwarding}
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
/>
</HostDetailsSection>
)}
@@ -687,7 +697,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.proxy")}
className="overflow-hidden"
>
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
{form.directProxy ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{t("hostDetails.proxy.direct")}
</Badge>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{t("hostDetails.proxy.directHint")}
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
aria-label={t("hostDetails.proxyPanel.remove")}
onClick={clearProxyConfig}
>
<X size={14} />
</Button>
</div>
) : 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"
@@ -2,6 +2,7 @@ import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
import { HostTotpFields } from "./HostTotpFields";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
distroOptions,
effectiveFormDistro,
getDistroOptionLabel,
totpFlushRef,
}) => {
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
</div>
)}
<HostTotpFields
t={t}
form={form}
setForm={setForm}
effectiveProtocol={effectiveProtocol}
flushRef={totpFlushRef}
/>
</div>
</HostDetailsSection>
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /COMMAND/);
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
assert.doesNotMatch(markup, /--token secret/);
});
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
+29 -4
View File
@@ -44,6 +44,7 @@ import {
} from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import type { HostTotpFlushResult } from "./HostTotpFields";
import {
LINUX_DISTRO_OPTION_IDS,
parseOptionalPortInput,
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
};
const { checkSshAgent } = useApplicationBackend();
const baselineSnippetsRef = useRef(snippets);
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
const savingHostRef = useRef(false);
const baselineHydratedRef = useRef(snippets.length > 0);
const openingQueueIdsRef = useRef<string[]>([]);
const [form, setForm] = useState<Host>(
@@ -453,7 +456,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
@@ -465,19 +468,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
return rest as Host;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return { ...rest, directProxy: true } as Host;
});
}, []);
const addHostToChain = (hostId: string) => {
setForm((prev) => ({
...prev,
@@ -527,7 +537,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
: hasHostname;
const handleSubmit = () => {
const handleSubmit = async () => {
if (savingHostRef.current) return;
savingHostRef.current = true;
try {
const flushed = await totpFlushRef.current?.();
if (flushed && !flushed.ok) return;
const hostname = form.hostname.trim()
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
if (!hostname || !canSaveHost) return;
@@ -608,6 +623,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
authPolicyVersion: 1,
managedSourceId: finalManagedSourceId,
};
if (flushed?.ok) {
if (flushed.totp) cleaned.totp = flushed.totp;
else delete cleaned.totp;
}
if (pluginProtocolActive) {
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
if (!pluginConnection) return;
@@ -735,6 +754,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}
}
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
} finally {
savingHostRef.current = false;
}
};
const handleCreateGroup = () => {
@@ -886,8 +908,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
directProxy={form.directProxy}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
@@ -1128,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
t={t}
form={form}
setForm={setForm}
totpFlushRef={totpFlushRef}
update={update}
groupDefaults={effectiveGroupDefaults}
effectiveAuthMethod={effectiveAuthMethod}
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import type { Host } from "../domain/models";
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
url: "http://localhost",
pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
install("window", dom.window);
install("document", dom.window.document);
install("navigator", dom.window.navigator);
install("HTMLElement", dom.window.HTMLElement);
install("HTMLInputElement", dom.window.HTMLInputElement);
install("HTMLButtonElement", dom.window.HTMLButtonElement);
install("Element", dom.window.Element);
install("Node", dom.window.Node);
install("MutationObserver", dom.window.MutationObserver);
install("Event", dom.window.Event);
install("CustomEvent", dom.window.CustomEvent);
install("MouseEvent", dom.window.MouseEvent);
install("KeyboardEvent", dom.window.KeyboardEvent);
install("FocusEvent", dom.window.FocusEvent);
install("PointerEvent", dom.window.PointerEvent);
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
Object.assign(dom.window, { netcatty: {} });
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTotpFields } = await import("./HostTotpFields.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const form = {
id: "host-1",
label: "Jump",
hostname: "127.0.0.1",
port: 22,
username: "root",
protocol: "ssh",
} as Host;
let latest = form;
const root = createRoot(dom.window.document.getElementById("root")!);
const t = (key: string) => key;
try {
await act(async () => {
root.render(
<TooltipProvider>
<HostTotpFields
t={t}
form={latest}
setForm={(updater) => {
latest = typeof updater === "function" ? updater(latest) : updater;
}}
effectiveProtocol="ssh"
/>
</TooltipProvider>,
);
});
const markup = dom.window.document.body.innerHTML;
assert.match(markup, /hostDetails\.totp\.setup/);
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
const button = [...dom.window.document.querySelectorAll("button")]
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
assert.ok(button);
await act(async () => { button.click(); });
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
assert.equal(latest.totp, undefined);
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
assert.match(fields, /setConfirmClear\(true\)/);
assert.match(fields, /variant="secondary"/);
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
const totpAt = sections.indexOf("<HostTotpFields");
assert.ok(passwordAt > 0 && totpAt > passwordAt);
} finally {
await act(async () => { root.unmount(); });
await new Promise((resolve) => setTimeout(resolve, 50));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+568
View File
@@ -0,0 +1,568 @@
import React, { useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { RE2JS } from "re2js";
import type { Host, HostTotpConfig } from "../domain/models";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { HostDetailsSettingRow } from "./host-details";
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
type Preview = {
uri: string;
code: string;
secondsRemaining: number;
issuer?: string;
label?: string;
};
export type HostTotpFlushResult =
| { ok: true; totp: HostTotpConfig | undefined }
| { ok: false };
type HostTotpFieldsProps = {
t: (key: string, params?: Record<string, string | number>) => string;
form: Host;
setForm: (updater: Host | ((host: Host) => Host)) => void;
effectiveProtocol?: string;
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
};
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
function errorKey(error: string | undefined): string {
switch (error) {
case "hotp":
return "hostDetails.totp.error.hotp";
case "migration":
return "hostDetails.totp.error.migration";
case "not-found":
return "hostDetails.totp.error.notFound";
case "screen-permission-denied":
return "hostDetails.totp.error.permission";
case "unavailable":
return "hostDetails.totp.error.unavailable";
case "pattern":
return "hostDetails.totp.error.pattern";
default:
return "hostDetails.totp.error.invalid";
}
}
async function decodeQrFile(file: File): Promise<string> {
const bitmap = await createImageBitmap(file);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("unavailable");
context.drawImage(bitmap, 0, 0);
const image = context.getImageData(0, 0, canvas.width, canvas.height);
return jsQR(image.data, image.width, image.height)?.data || "";
} finally {
bitmap.close?.();
}
}
function validPattern(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.length > 200) return null;
try {
RE2JS.compile(trimmed);
return trimmed;
} catch {
return null;
}
}
function importedAccount(preview: Preview): string {
const issuer = preview.issuer?.trim() || "";
const label = preview.label?.trim() || "";
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
return `${issuer} (${label})`;
}
return label || issuer;
}
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
const [preview, setPreview] = useState<Preview | null>(null);
const hostRef = useRef(hostId);
hostRef.current = hostId;
useEffect(() => {
if (!source) {
setPreview(null);
return undefined;
}
const expectedHostId = hostId;
let cancelled = false;
const bridge = netcattyBridge.get();
if (!bridge?.previewTotp) return undefined;
const endsAtRef = { current: 0 };
let refreshed = false;
const apply = (next: Preview) => {
if (cancelled || hostRef.current !== expectedHostId) return;
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
refreshed = false;
setPreview(next);
};
void bridge.previewTotp({ text: source }).then((result) => {
if (!result?.ok || !result.uri || !result.code) return;
apply({
uri: result.uri,
code: result.code,
secondsRemaining: result.secondsRemaining || result.period || 30,
issuer: result.issuer,
label: result.label,
});
}).catch(() => undefined);
const timer = setInterval(() => {
if (cancelled || !endsAtRef.current) return;
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
setPreview((current) => (
current && current.secondsRemaining !== left
? { ...current, secondsRemaining: left }
: current
));
if (left !== 0 || refreshed) return;
refreshed = true;
void bridge.previewTotp({ text: source }).then((result) => {
if (!result?.ok || !result.uri || !result.code) return;
apply({
uri: result.uri,
code: result.code,
secondsRemaining: result.secondsRemaining || result.period || 30,
issuer: result.issuer,
label: result.label,
});
}).catch(() => undefined);
}, 250);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [hostId, source]);
return source ? preview : null;
}
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
t,
form,
setForm,
effectiveProtocol,
flushRef,
}) => {
const configured = Boolean(form.totp?.uri);
const [setupOpen, setSetupOpen] = useState(false);
const [draft, setDraft] = useState("");
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const hostIdRef = useRef(form.id);
const formRef = useRef(form);
const setupOpenRef = useRef(setupOpen);
const draftRef = useRef(draft);
const patternRef = useRef(pattern);
const autoSubmitRef = useRef(autoSubmit);
const modalUriRef = useRef(modalUri);
const requestRef = useRef(0);
formRef.current = form;
setupOpenRef.current = setupOpen;
draftRef.current = draft;
patternRef.current = pattern;
autoSubmitRef.current = autoSubmit;
modalUriRef.current = modalUri;
const [trackedHostId, setTrackedHostId] = useState(form.id);
if (form.id !== trackedHostId) {
setTrackedHostId(form.id);
setSetupOpen(false);
setDraft("");
setPattern(form.totp?.promptPattern || "");
setAutoSubmit(form.totp?.autoSubmit !== false);
setModalUri(undefined);
setError(null);
setBusy(false);
setScanning(false);
setConfirmClear(false);
draftRef.current = "";
patternRef.current = form.totp?.promptPattern || "";
autoSubmitRef.current = form.totp?.autoSubmit !== false;
modalUriRef.current = undefined;
setupOpenRef.current = false;
}
hostIdRef.current = form.id;
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
const writeTotp = (next: HostTotpConfig | undefined) => {
const expectedHostId = hostIdRef.current;
setForm((current) => {
if (current.id !== expectedHostId) return current;
const updated = { ...current };
if (next?.uri) updated.totp = next;
else delete updated.totp;
return updated;
});
};
const previewText = async (text: string): Promise<string | null> => {
const expectedHostId = hostIdRef.current;
const requestId = ++requestRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.previewTotp) {
setError("unavailable");
return null;
}
setBusy(true);
try {
const result = await bridge.previewTotp({ text });
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
if (!result?.ok || !result.uri || !result.code) {
setError(result?.error || "invalid");
return null;
}
setError(null);
setModalUri(result.uri);
modalUriRef.current = result.uri;
setDraft("");
draftRef.current = "";
return result.uri;
} catch {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
return null;
} finally {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
}
};
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
const expectedHostId = hostIdRef.current;
const patternValue = validPattern(patternRef.current);
if (patternValue == null) {
setError("pattern");
return { ok: false };
}
let uri = modalUriRef.current || formRef.current.totp?.uri;
const text = draftRef.current.trim();
if (text) {
uri = await previewText(text) || undefined;
if (hostIdRef.current !== expectedHostId) return { ok: false };
if (!uri) return { ok: false };
}
if (!uri) {
if (options?.requireSecret) {
setError("invalid");
return { ok: false };
}
return { ok: true, totp: formRef.current.totp };
}
const next: HostTotpConfig = {
uri,
autoSubmit: autoSubmitRef.current,
...(patternValue ? { promptPattern: patternValue } : {}),
};
writeTotp(next);
setSetupOpen(false);
setupOpenRef.current = false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
return { ok: true, totp: next };
};
const commitRef = useRef(commitSetup);
commitRef.current = commitSetup;
useEffect(() => {
if (!flushRef) return undefined;
const flush = async (): Promise<HostTotpFlushResult> => {
const expectedHostId = hostIdRef.current;
if (hostIdRef.current !== expectedHostId) return { ok: false };
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
return commitRef.current();
};
flushRef.current = flush;
return () => {
if (flushRef.current === flush) flushRef.current = null;
};
}, [flushRef]);
const openSetup = () => {
setPattern(form.totp?.promptPattern || "");
patternRef.current = form.totp?.promptPattern || "";
setAutoSubmit(form.totp?.autoSubmit !== false);
autoSubmitRef.current = form.totp?.autoSubmit !== false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
setSetupOpen(true);
setupOpenRef.current = true;
};
const cancelSetup = () => {
requestRef.current += 1;
setSetupOpen(false);
setupOpenRef.current = false;
setDraft("");
draftRef.current = "";
setModalUri(undefined);
modalUriRef.current = undefined;
setError(null);
setBusy(false);
setScanning(false);
};
const clearTotp = () => {
cancelSetup();
writeTotp(undefined);
};
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
const scanScreen = async () => {
const expectedHostId = form.id;
const requestId = ++requestRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.scanTotpQrFromScreen) {
setError("unavailable");
return;
}
setBusy(true);
setScanning(true);
try {
const result = await bridge.scanTotpQrFromScreen();
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
if (!result?.ok || !result.uri || !result.code) {
setError(result?.error || "not-found");
return;
}
setError(null);
setModalUri(result.uri);
modalUriRef.current = result.uri;
setDraft("");
draftRef.current = "";
} catch {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
} finally {
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
setBusy(false);
setScanning(false);
}
}
};
return (
<>
<HostDetailsSettingRow
label={t("hostDetails.totp.title")}
hint={t("hostDetails.totp.warning")}
>
<div className="flex items-center gap-2">
{rowPreview && (
<span
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
>
{rowPreview.code}
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={openSetup}
>
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
</Button>
</div>
</HostDetailsSettingRow>
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
<Input
id="host-totp-secret"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
draftRef.current = event.target.value;
}}
onBlur={() => {
if (draftRef.current.trim()) void previewText(draftRef.current);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commitSetup({ requireSecret: true });
}
}}
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
aria-label={t("hostDetails.totp.secret")}
className="h-8 font-mono text-xs"
disabled={busy}
spellCheck={false}
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
{t("hostDetails.totp.importQr")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
disabled={busy}
onClick={() => { void scanScreen(); }}
>
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const expectedHostId = form.id;
const requestId = ++requestRef.current;
setBusy(true);
void decodeQrFile(file)
.then((text) => {
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
if (!text) {
setError("not-found");
setBusy(false);
return;
}
return previewText(text);
})
.catch(() => {
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
setError("invalid");
setBusy(false);
});
}}
/>
</div>
{modalPreview && (
<div className="space-y-1">
{importedAccount(modalPreview) ? (
<p className="text-sm text-foreground">
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
</p>
) : null}
<p className="text-sm text-muted-foreground">
{t("hostDetails.totp.preview")}
{" "}
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
</div>
<Switch
checked={autoSubmit}
onCheckedChange={(checked) => {
setAutoSubmit(checked);
autoSubmitRef.current = checked;
}}
aria-label={t("hostDetails.totp.autoSubmit")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
<Input
id="host-totp-pattern"
value={pattern}
onChange={(event) => {
setPattern(event.target.value);
patternRef.current = event.target.value;
setError((current) => (current === "pattern" ? null : current));
}}
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
aria-label={t("hostDetails.totp.promptPattern")}
className="h-8 font-mono text-xs"
spellCheck={false}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{configured ? (
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
{t("hostDetails.totp.clear")}
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={cancelSetup}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
{t("common.save")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{confirmClear ? (
<SystemPanelConfirmDialog
open
destructive
title={t("hostDetails.totp.removeTitle")}
message={t("hostDetails.totp.removeMessage")}
confirmLabel={t("hostDetails.totp.clear")}
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
onConfirm={() => {
setConfirmClear(false);
clearTotp();
}}
/>
) : null}
</>
);
};
+81 -35
View File
@@ -4,48 +4,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
const require = createRequire(import.meta.url);
import {
keyboardInteractiveFieldLabel,
keyboardInteractiveGuidance,
totpFillPlacement,
visibleTotpFillPlacements,
} from "./KeyboardInteractiveModal.tsx";
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
const text = formatKeyboardInteractiveServerPrompt({
hostname: "192.168.9.138",
name: "Keyboard-interactive authentication prompts from server",
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
prompts: [
{
prompt: "Secondary Authentication Password:",
echo: false,
},
],
});
assert.equal(
text,
[
"Keyboard-interactive authentication prompts from server:",
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
"| Secondary Authentication Password:",
].join("\n"),
);
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
});
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
const text = formatKeyboardInteractiveServerPrompt({
hostname: "192.168.9.138",
name: "192.168.9.138",
instructions: "",
prompts: [
{
prompt: "Password:",
echo: false,
},
],
});
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
assert.doesNotMatch(modalSource, /<pre/);
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
assert.match(modalSource, /variant="secondary"/);
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
const esm = await import("../domain/totpPrompt.shared.mjs");
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
assert.doesNotMatch(modalSource, /ring-primary/);
assert.doesNotMatch(modalSource, /className="w-full"/);
assert.doesNotMatch(modalSource, /onTypeTotp/);
});
assert.equal(text, "");
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "",
prompts: [{ prompt: "Verification code:", echo: false }],
}), "");
assert.equal(keyboardInteractiveGuidance({
name: "Duo Security",
hostname: "vpn.example",
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
}), "Duo Security\n1. Duo Push\n2. SMS");
assert.equal(keyboardInteractiveGuidance({
name: "host",
hostname: "other",
instructions: "| Verification code:",
prompts: [{ prompt: "Verification code:", echo: false }],
}), "host");
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "",
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
}), "1. OTP\n2. SMS");
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "1. OTP",
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
}), "1. OTP");
});
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
assert.deepEqual(visibleTotpFillPlacements([
{ prompt: "Password:", echo: false },
{ prompt: "Verification code:", echo: false },
]), ["hidden", "preferred"]);
assert.deepEqual(visibleTotpFillPlacements([
{ prompt: "Password:", echo: false },
]), ["hidden"]);
});
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
+115 -39
View File
@@ -5,6 +5,7 @@
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
allowSavePassword?: boolean;
/** otpauth URI from the connection attempt that opened this dialog. */
totpUri?: string;
}
type KeyboardInteractiveServerPromptInput = Pick<
KeyboardInteractiveRequest,
"name" | "instructions" | "prompts" | "hostname"
>;
/** Last meaningful line of a server prompt, without the raw banner. */
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const last = lines.at(-1) || "";
const cleaned = last
.replace(/^[\s||>•*-]+/, "")
.replace(/[::]\s*$/, "")
.trim()
.replace(/^\[(.+)\]$/, "$1")
.trim();
return cleaned || fallback;
}
/** Formats the server-supplied keyboard-interactive prompt block for display. */
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
const lines: string[] = [];
const name = request.name?.trim();
const hostname = request.hostname?.trim();
const instructions = request.instructions?.trim();
const hasServerText = !!instructions || !!(name && name !== hostname);
if (!hasServerText) return "";
if (name && name !== hostname) {
lines.push(name.endsWith(":") ? name : `${name}:`);
}
if (instructions) {
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
lines.push(`| ${line}`);
}
}
function guidanceKeys(text: string): string[] {
const trimmed = text.trim().toLowerCase();
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
return [trimmed, label].filter(Boolean);
}
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
const labelKeys = new Set<string>();
const promptLines: string[] = [];
for (const prompt of request.prompts || []) {
const promptText = prompt.prompt?.trim();
if (promptText) {
lines.push(`| ${promptText}`);
}
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const last = parts.at(-1) || "";
for (const key of guidanceKeys(last)) labelKeys.add(key);
promptLines.push(...parts.slice(0, -1));
}
const seen = new Set<string>();
const lines: string[] = [];
const push = (text: string) => {
const line = text.trim().replace(/^\|\s*/, "");
if (!line) return;
const keys = guidanceKeys(line);
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
for (const key of keys) seen.add(key);
lines.push(line);
};
const hostname = (request.hostname || "").trim().toLowerCase();
const name = (request.name || "").trim();
if (name && name.toLowerCase() !== hostname) push(name);
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
for (const part of promptLines) push(part);
return lines.join("\n");
}
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
onCancel: (requestId: string) => boolean | Promise<boolean>;
canFillTotp?: boolean;
onFillTotp?: () => Promise<string | null> | string | null;
}
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
export type TotpFillPlacement = "preferred" | "available" | "hidden";
function lastPromptLine(prompt: string | undefined): string {
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return lines.at(-1) || "";
}
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
const rawLast = lastPromptLine(prompt.prompt);
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
if (OTP_PROMPT.test(label)) return "preferred";
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
return "available";
}
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
const placements = prompts.map(totpFillPlacement);
const hasPreferred = placements.some((placement) => placement === "preferred");
if (!hasPreferred) return placements;
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
canFillTotp = false,
onFillTotp,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
}
}, [request, onCancel, isSubmitting]);
const handleFillTotp = useCallback(async (index: number) => {
if (!request || isSubmitting || !onFillTotp) return;
const prompt = request.prompts[index];
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
const code = await onFillTotp();
if (!code) return;
handleResponseChange(index, code);
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
if (!request) return null;
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
const title = t("keyboard.interactive.title");
const description =
request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc");
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
const guidance = keyboardInteractiveGuidance(request);
return (
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</DialogHeader>
<div className="space-y-4 py-2">
{serverPromptText && (
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
{serverPromptText}
</pre>
)}
{guidance ? (
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
{guidance}
</p>
) : null}
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
const promptLabel = keyboardInteractiveFieldLabel(
prompt.prompt,
t("keyboard.interactive.verificationCode"),
);
const fillPlacement = fillPlacements[index] ?? "hidden";
return (
<div key={index} className="space-y-2">
{showPromptLabel && (
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
{promptLabel}
</Label>
)}
{fillPlacement !== "hidden" && (
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
data-totp-fill={fillPlacement}
onClick={() => { void handleFillTotp(index); }}
disabled={isSubmitting}
>
{t("keyboard.interactive.fillTotp")}
</Button>
)}
</div>
<div className="relative">
<Input
id={`ki-prompt-${index}`}
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
onKeyDown={handleKeyDown}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
disabled={isSubmitting}
/>
{isPassword && (
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button
+2
View File
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
import { sanitizeCredentialValue } from "../domain/credentials";
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { connectionTotpFields } from "../domain/totpConfig";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
enableKeyboardInteractive: true,
requiresMfa: !!effectiveExportHost.requiresMfa,
...connectionTotpFields(effectiveExportHost),
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
});
+7
View File
@@ -27,6 +27,10 @@ import {
removeProxyProfileReferences,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import {
clearActiveGlobalDefaultProxyProfile,
getActiveGlobalDefaultProxyProfileId,
} from "../domain/globalDefaultProxy";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
@@ -391,6 +395,9 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const confirmDelete = () => {
if (!deleteTarget) return;
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
clearActiveGlobalDefaultProxyProfile();
}
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
hosts,
groupConfigs,
+9 -1
View File
@@ -10,6 +10,7 @@ import {
User,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
: `${protocolLabel} ${hostEndpoint}`
);
return (
const wizard = (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div>
</div>
);
// The vault surface is visibility:hidden while a terminal tab is active.
// Portal so the same wizard opened from the tab-bar switcher stays visible.
if (typeof document !== "undefined" && document.body) {
return createPortal(wizard, document.body);
}
return wizard;
};
export default QuickConnectWizard;
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import type { Host, TerminalSession } from "../types";
import { QuickSwitcher } from "./QuickSwitcher";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "saved-1",
label: "core-switch",
hostname: "10.1.1.1",
username: "admin",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
}) as Host;
const renderSwitcher = (
query: string,
results: Host[] = [],
locale = "en",
) => renderToStaticMarkup(
<I18nProvider locale={locale}>
<QuickSwitcher
isOpen
query={query}
results={results}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
test("quick switcher keeps a matching host ahead of an address row", () => {
const markup = renderSwitcher("10.1.1.1", [host()]);
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
const hostAt = markup.indexOf("core-switch");
const selectedAt = markup.indexOf("bg-primary/15");
assert.ok(selectedAt >= 0);
assert.ok(hostAt > selectedAt);
assert.ok(quickConnectAt > hostAt);
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
});
test("quick switcher does not steal Enter from a saved host label", () => {
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
const hostAt = markup.indexOf(`>${label}<`);
const selectedAt = markup.indexOf("bg-primary/15");
assert.equal(markup.includes("Quick connect to"), false, label);
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
}
});
test("quick switcher keeps a matching open session ahead of an address row", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<QuickSwitcher
isOpen
query="10.4.4.4"
results={[]}
sessions={[{
id: "sess-1",
hostId: "host-1",
hostLabel: "open-core",
username: "admin",
hostname: "10.4.4.4",
status: "connected",
} as TerminalSession]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
const sessionAt = markup.indexOf("open-core");
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
const selectedAt = markup.indexOf("bg-primary/15");
assert.ok(selectedAt >= 0);
assert.ok(sessionAt > selectedAt);
assert.ok(quickConnectAt > sessionAt);
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
});
test("quick switcher offers quick connect when nothing else matches", () => {
const markup = renderSwitcher("root@10.2.0.8:2222");
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
});
test("quick switcher does not offer quick connect for ordinary search text", () => {
const markup = renderSwitcher("core switch");
assert.doesNotMatch(markup, /Quick connect to/);
});
test("quick switcher quick connect row uses zh-TW wording", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-TW">
<QuickSwitcher
isOpen
query="10.0.0.8"
results={[host()]}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
onCreateWorkspace={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
assert.match(markup, /快速連線到 10\.0\.0\.8/);
assert.match(markup, />主機</);
assert.match(markup, />新增工作區</);
assert.doesNotMatch(markup, /New Workspace/);
});
test("quick switcher section labels use zh-CN wording", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-CN">
<QuickSwitcher
isOpen
query=""
results={[host()]}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
onCreateWorkspace={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
assert.match(markup, />主机</);
assert.match(markup, />标签页</);
assert.match(markup, />新建工作区</);
});
+50 -9
View File
@@ -14,6 +14,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import type { PaneMagnificationState } from "../domain/paneMagnification";
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
import { matchesSearchQuery } from "../lib/searchMatcher";
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
import { usePluginContributions } from "../application/state/usePluginContributions";
@@ -30,11 +31,12 @@ type QuickSwitcherItemBase = {
shortcut?: string;
pluginId?: string;
icon?: NetcattyPluginIconReference;
quickConnectInput?: string;
};
type QuickSwitcherItem = QuickSwitcherItemBase & (
| { type: "plugin-command"; commandId: string }
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
);
export function buildPaneMagnificationPaletteAction(
@@ -165,6 +167,7 @@ interface QuickSwitcherProps {
paneMagnificationState?: PaneMagnificationState;
onMagnifyCurrentPane?: () => void;
onRestoreMagnifiedPane?: () => void;
onQuickConnect?: (input: string) => void;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
paneMagnificationState = 'unavailable',
onMagnifyCurrentPane,
onRestoreMagnifiedPane,
onQuickConnect,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -335,19 +339,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
};
if (results.length > 0) {
pushHeader("header:hosts", "Hosts");
results.forEach((host) =>
pushItem({ type: "host", id: host.id, data: host }),
);
}
const quickConnectItem: QuickSwitcherItem | null =
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
? {
type: "quick-connect",
id: "quick-connect",
title: t("qs.quickConnectTo", { target: trimmedQuery }),
quickConnectInput: trimmedQuery,
}
: null;
const hasTabsSection =
builtInTabs.length > 0
|| filteredOrphanSessions.length > 0
|| filteredWorkspaces.length > 0;
// The address row is the default Enter target only when nothing else matches.
// Matching hosts and open tabs stay ahead of it.
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
pushItem(quickConnectItem);
}
if (results.length > 0) {
pushHeader("header:hosts", t("qs.hosts"));
results.forEach((host) =>
pushItem({ type: "host", id: host.id, data: host }),
);
if (quickConnectItem) pushItem(quickConnectItem);
}
if (hasTabsSection) {
pushHeader("header:tabs", "Tabs");
pushHeader("header:tabs", t("qs.tabs"));
builtInTabs.forEach((tabId) => {
pushItem({ type: "tab", id: tabId });
});
@@ -357,6 +379,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
filteredOrphanSessions.forEach((s) =>
pushItem({ type: "tab", id: s.id, data: s }),
);
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
}
if (filteredShells.length > 0) {
@@ -445,6 +468,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
switch (item.type) {
case "quick-connect":
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
onClose();
break;
case "host":
onSelect(item.data as Host);
break;
@@ -542,7 +569,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
>
<Plus size={11} />
<span>New Workspace</span>
<span>{t("qs.newWorkspace")}</span>
</button>
)}
</div>
@@ -576,6 +603,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
if (item.type === "quick-connect") {
return (
<div
className={rowClass}
onClick={() => handleItemSelect(item)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
<Terminal size={16} />
</div>
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
</div>
);
}
if (item.type === "host") {
const host = item.data as Host;
const hostRow = (
+8
View File
@@ -141,6 +141,8 @@ type TerminalTabSettingsProps = Pick<
| 'setLocalShellSidePanelAutoOpenTab'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
| 'globalDefaultProxyProfileId'
| 'setGlobalDefaultProxyProfileId'
>;
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
@@ -171,6 +173,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setLocalShellSidePanelAutoOpenTab,
workspaceFocusStyle,
setWorkspaceFocusStyle,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
}) {
const availableFonts = useAvailableFonts();
@@ -204,6 +208,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
availableFonts={availableFonts}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
/>
);
});
@@ -599,6 +605,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
/>
</SettingsLazyTab>
)}
+8 -4
View File
@@ -38,6 +38,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
@@ -161,16 +162,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
resolveTransferSourceSessionId,
]);
// Pre-resolve group defaults so SFTP connections inherit group config
// Memoized on vault props, so this subscription is what refreshes the list
// after the global default changes.
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
const effectiveHosts = useMemo(() => {
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
return hosts.map(h => {
const withGroupDefaults = h.group
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
: applyGroupDefaults(h, {}, { validProxyProfileIds });
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
: applyGroupDefaults(h, {}, inheritanceOptions);
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
});
}, [hosts, groupConfigs, proxyProfiles]);
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
const hostWriteSource = writableHosts ?? hosts;
+45 -14
View File
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
appendTerminalPromptSecurityTail,
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity";
@@ -163,8 +163,12 @@ import {
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
import {
applyUserCursorPreference,
applyUserCursorWidthPreference,
@@ -439,7 +443,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const recorder = useScriptRecorder(sessionId);
const recorderRef = useRef(recorder);
recorderRef.current = recorder;
const passwordPromptActiveRef = useRef(false);
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
const terminalTitleRef = useRef<string | undefined>(undefined);
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
useEffect(() => registerTerminalSensitiveInputReader(
@@ -448,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
), [sessionId]);
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
const sensitivePromptOutputTailRef = useRef("");
const heredocPromptOriginRef = useRef("");
const promptSecurityPendingCrRef = useRef(false);
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -599,18 +605,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
const term = termRef.current;
if (!term) return false;
const prompt = detectPrompt(term);
return isIdleShellReadyForCommandInjection({
const readiness = {
sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length,
});
};
return options?.allowDraft
? isShellPromptReadyForInsertion(readiness)
: isIdleShellReadyForCommandInjection(readiness);
}), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
@@ -1695,6 +1704,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (typeof payload.passwordPromptActive === "boolean") {
passwordPromptActiveRef.current = payload.passwordPromptActive;
// The snapshot boolean has no source. A restored true latch stays
// explicit so Email:/Username: cannot bypass a plugin-marked field.
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
}
if (payload.cwd !== undefined) {
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
@@ -2001,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string,
meta?: TerminalSessionDataMeta,
) => {
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
sensitivePromptOutputTailRef.current,
chunk,
);
const folded = foldTerminalPromptSecurityOutput({
origin: heredocPromptOriginRef.current,
current: sensitivePromptOutputTailRef.current,
pendingCr: promptSecurityPendingCrRef.current,
}, chunk);
heredocPromptOriginRef.current = folded.origin;
sensitivePromptOutputTailRef.current = folded.current;
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
@@ -2013,23 +2029,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
// Authoritative metadata must not be downgraded to the heuristic latch,
// or trusted labels such as Email: would bypass snippet blocking.
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
if (meta.pluginPipelineSensitiveInput) {
autocompleteCloseRef.current?.();
} else {
sensitivePromptOutputTailRef.current = "";
heredocPromptOriginRef.current = "";
promptSecurityPendingCrRef.current = false;
}
return;
} else if (isUntrustedTerminalInputPrompt(
sensitivePromptOutputTailRef.current,
folded.promptText,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt(
sensitivePromptOutputTailRef.current,
folded.promptText,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
}
}, [isNetworkDevice]);
@@ -2341,6 +2364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}), []);
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
@@ -2430,7 +2455,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const pluginAwareOnCommandCompleted = useCallback(() => {
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
// Hibernate wake builds its runtime from this callback. Same local
// prompt-return refresh as useTerminalEffects (#1952).
if (host.protocol === "local") {
onCommandSubmitted?.("", host.id, host.label, sessionId);
}
}, [host.id, host.label, host.protocol, onCommandSubmitted, pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -4331,6 +4361,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
xTermRuntimeContextRef.current = {
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize: effectiveFontSize,
@@ -4654,7 +4685,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
+9 -3
View File
@@ -6,7 +6,6 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
import {
@@ -114,7 +113,7 @@ import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
resolveSessionSidePanelAutoOpen,
} from '../domain/terminalSidePanelAutoOpen';
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import {
resolvePreferredTerminalCwd,
scheduleBackendCwdProbeAfterCommand,
@@ -1348,7 +1347,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const tabId = activeTabIdRef.current;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return;
// Local POSIX shells do not emit OSC 7. They share this probe so session
// restore can `cd` back after restart (#1952). Prompt return calls this
// again with an empty command once the shell is idle, so a slow command
// such as `sleep 1; cd /tmp` is not recorded at the 150ms snapshot.
// SSH keeps the extra-exec eligibility check inside canScheduleCommandCwdProbe.
if (!session || !canScheduleCommandCwdProbe(session)) return;
const sessionHost = sessionHostsMapRef.current.get(sessionId);
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
? sftpHostForTabRef.current.get(tabId) ?? null
@@ -1372,6 +1376,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
canProbe: async () => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
// Local cwd is the PTY process directory, not an extra SSH channel.
if (session.protocol === "local") return true;
const host = sessionHostsMapRef.current.get(sessionId);
if (!host) return false;
const hostDeviceClass = classifyDistroId(host.distro);
+9 -1
View File
@@ -44,6 +44,7 @@ import React, {
useState,
} from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredNumber } from "../application/state/useStoredNumber";
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
[],
);
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
setQuickConnectTarget(target);
setQuickConnectWarnings(warnings);
setIsQuickConnectOpen(true);
}), []);
// Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
null,
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
// Only these terminal connection settings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them.
// and we don't want to re-render for them. onConnect is also omitted:
// temporary-connection toggles are read from a ref at connect time.
prev.terminalSettings?.verifyHostKeys ===
next.terminalSettings?.verifyHostKeys &&
prev.terminalSettings?.keepaliveInterval ===
+14
View File
@@ -51,3 +51,17 @@ test("Vault import entry is disabled while an import is running", () => {
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
});
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
assert.ok(menuStart >= 0);
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
assert.doesNotMatch(menu, /className="w-44"/);
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
});
+28 -3
View File
@@ -4,7 +4,7 @@
* even when the Catty AI side panel has never been opened.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ToolCall } from '../ai-elements/tool-call';
import { Button } from '../ui/button';
import {
@@ -20,6 +20,13 @@ import {
resolveCapabilityId,
} from '../../infrastructure/ai/harness/permissionGrants';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
import {
externalMcpApprovalToolLabel,
notifyExternalMcpApproval,
publishExternalMcpApprovalAlertMode,
} from '../../application/state/externalMcpApprovalAlert';
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
@@ -31,19 +38,37 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
export const ExternalMcpApprovalsHost: React.FC = () => {
const { t } = useI18n();
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const alertedIdsRef = useRef(new Set<string>());
useEffect(() => {
const pushMode = () => {
publishExternalMcpApprovalAlertMode();
};
pushMode();
const onChange = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
};
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
}, []);
useEffect(() => {
const handler = (request: ApprovalRequest) => {
if (!isExternalMcpApproval(request)) return;
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
if (alertedIdsRef.current.has(request.toolCallId)) return;
alertedIdsRef.current.add(request.toolCallId);
notifyExternalMcpApproval(request, t);
};
const unsub = onApprovalRequest(handler);
replayPendingApprovals(handler);
return unsub;
}, []);
}, [t]);
useEffect(() => {
return onApprovalCleared((clearedIds) => {
for (const id of clearedIds) alertedIdsRef.current.delete(id);
setPendingApprovals((prev) => {
const next = new Map(prev);
for (const id of clearedIds) next.delete(id);
@@ -129,7 +154,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
{entries.map(([id, req]) => (
<div key={id} className="space-y-1.5">
<ToolCall
name={req.toolName}
name={externalMcpApprovalToolLabel(req.toolName)}
args={req.args}
approvalTarget={req.target}
isLoading={false}
+27 -9
View File
@@ -27,8 +27,10 @@ export interface ProxyPanelProps {
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
selectedProxyProfileId?: string;
directProxy?: boolean;
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onSelectDirectProxy?: () => void;
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
@@ -42,8 +44,10 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
proxyProfiles = [],
identities = [],
selectedProxyProfileId,
directProxy = false,
onUpdateProxy,
onSelectProxyProfile,
onSelectDirectProxy,
onClearProxy,
onBack,
onCancel,
@@ -54,13 +58,18 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
}) => {
const { t } = useI18n();
const customValue = '__custom__';
const directValue = '__direct__';
const selectedProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
[proxyProfiles, selectedProxyProfileId],
);
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
const isUsingProfile = Boolean(selectedProfile);
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
const selectedValue = directProxy
? directValue
: selectedProfile
? selectedProfile.id
: customValue;
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
const isCommandProxy = isProxyCommandConfig(proxyConfig);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
@@ -71,8 +80,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const manualCredentialsValue = '__manual_credentials__';
const missingIdentityValue = '__missing_identity__';
const selectedIdentity = useMemo(
@@ -103,7 +112,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
}
>
<AsidePanelContent>
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && onSelectProxyProfile && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
@@ -111,7 +120,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</div>
<Select
value={selectedValue}
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
onValueChange={(value) => {
if (value === directValue) {
onSelectDirectProxy?.();
return;
}
onSelectProxyProfile(value === customValue ? undefined : value);
}}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.savedProxy')}
@@ -120,6 +135,9 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
</SelectTrigger>
<SelectContent>
{onSelectDirectProxy && (
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
)}
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
{proxyProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
@@ -163,7 +181,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</Card>
)}
{!isUsingProfile && (
{!isUsingProfile && !directProxy && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="space-y-2">
@@ -328,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</>
)}
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>
@@ -5,10 +5,13 @@ import type {
CursorShape,
HostInfoBarTitleMode,
PasswordPromptAssistMode,
ProxyProfile,
TerminalEmulationType,
TerminalSettings,
} from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
@@ -43,6 +46,34 @@ import {
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
} from "../../../domain/terminalInlineImages";
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
const readSavedProxyProfiles = (): ProxyProfile[] => {
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
return Array.isArray(stored) ? stored : [];
};
const useSavedProxyProfiles = (): ProxyProfile[] => {
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
useEffect(() => {
const refresh = (key?: string | null) => {
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
setProfiles(readSavedProxyProfiles());
};
const onAdapterChange = (event: Event) => {
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
};
const onStorage = (event: StorageEvent) => refresh(event.key);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
window.removeEventListener("storage", onStorage);
};
}, []);
return profiles;
};
const FONT_WEIGHT_OPTIONS = [
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
@@ -87,6 +118,8 @@ function SettingsTerminalTab(props: {
availableFonts: TerminalFont[];
workspaceFocusStyle: 'dim' | 'border';
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
globalDefaultProxyProfileId?: string;
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
}) {
const {
terminalThemeId,
@@ -117,8 +150,14 @@ function SettingsTerminalTab(props: {
availableFonts,
workspaceFocusStyle,
setWorkspaceFocusStyle,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
} = props;
const { t } = useI18n();
const proxyProfiles = useSavedProxyProfiles();
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
: GLOBAL_DEFAULT_PROXY_NONE;
// Local shell settings state
const [defaultShell, setDefaultShell] = useState<string>("");
@@ -932,6 +971,23 @@ function SettingsTerminalTab(props: {
<SectionHeader title={t("settings.terminal.section.connection")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="terminal-default-proxy"
label={t("settings.terminal.connection.defaultProxy")}
description={t("settings.terminal.connection.defaultProxy.desc")}
>
<Select
value={defaultProxyValue}
onChange={(value) => {
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
}}
options={[
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
]}
className="w-56"
/>
</SettingRow>
<SettingRow
anchorId="terminal-verify-host-keys"
label={t("settings.terminal.connection.verifyHostKeys")}
@@ -942,6 +998,26 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-temporary-legacy-algorithms"
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
>
<Toggle
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-temporary-network-device-mode"
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
>
<Toggle
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-ssh-auto-reconnect"
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
@@ -0,0 +1,62 @@
import React from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
+98 -59
View File
@@ -1,17 +1,31 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpClientHost,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpClientHost,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
type ExternalMcpApprovalAlertMode,
type ExternalMcpHttpSettings,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
@@ -19,70 +33,14 @@ import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
@@ -97,6 +55,7 @@ type ExternalMcpStatus = {
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
http?: ExternalMcpHttpViewStatus | null;
};
type ClientSetupStatus = {
@@ -297,8 +256,19 @@ export const ExternalMcpCard: React.FC = () => {
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
() => readExternalMcpApprovalAlertMode(),
);
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
}));
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
@@ -344,11 +314,51 @@ export const ExternalMcpCard: React.FC = () => {
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []);
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
const normalized: ExternalMcpHttpSettings = {
enabled: writeExternalMcpHttpEnabled(next.enabled),
port: writeExternalMcpHttpPort(next.port),
allowCustomBind: writeExternalMcpHttpAllowCustomBind(next.allowCustomBind),
listenAddress: writeExternalMcpHttpListenAddress(next.listenAddress),
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
};
setHttpSettings(normalized);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
});
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
}, []);
const rotateHttpToken = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpSetConfig) return;
setIsRotatingHttpToken(true);
setActionMessage(null);
try {
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
} finally {
setIsRotatingHttpToken(false);
}
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
@@ -698,6 +708,24 @@ export const ExternalMcpCard: React.FC = () => {
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-approval-alert"
label={t("ai.externalMcp.approvalAlert")}
description={t("ai.externalMcp.approvalAlert.description")}
>
<Select
value={approvalAlert}
options={[
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
]}
onChange={(value) => setApprovalAlert(
value === "always" || value === "off" ? value : "unfocused",
)}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")}
@@ -778,6 +806,17 @@ export const ExternalMcpCard: React.FC = () => {
</SettingRow>
</SettingCard>
<ExternalMcpHttpSection
externalMcpEnabled={enabled}
settings={httpSettings}
status={status?.http || null}
copied={copied}
rotating={isRotatingHttpToken}
onCopy={copyText}
onChange={(next) => { void applyHttpSettings(next); }}
onRotateToken={() => { void rotateHttpToken(); }}
/>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
@@ -0,0 +1,323 @@
import React, { useState } from "react";
import {
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
buildCodexHttpToml,
buildExternalMcpHttpUrl,
buildGenericHttpMcpJson,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
} from "../../../../domain/externalMcpHttp";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
export type ExternalMcpHttpViewStatus = {
enabled?: boolean;
listening?: boolean;
port?: number;
listenAddress?: string;
allowCustomBind?: boolean;
clientHost?: string;
url?: string | null;
bearerToken?: string | null;
state?: string;
error?: string | null;
errorCode?: string | null;
};
export type ExternalMcpHttpViewSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpHttpSectionProps = {
externalMcpEnabled: boolean;
settings: ExternalMcpHttpViewSettings;
status: ExternalMcpHttpViewStatus | null;
copied: string | null;
rotating: boolean;
onCopy: (key: string, text: string) => void;
onChange: (next: ExternalMcpHttpViewSettings) => void;
onRotateToken: () => void;
};
function isWildcardAddress(value: string): boolean {
const address = value.trim();
return address === "0.0.0.0" || address === "::";
}
function isIpAddress(value: string): boolean {
const address = value.trim();
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
}
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
}
function parsePort(value: string): number | null {
if (!/^\d+$/.test(value.trim())) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
return port;
}
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
externalMcpEnabled,
settings,
status,
copied,
rotating,
onCopy,
onChange,
onRotateToken,
}) => {
const { t } = useI18n();
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
const [portError, setPortError] = useState(false);
const [addressError, setAddressError] = useState(false);
const [clientHostError, setClientHostError] = useState(false);
const listenAddress = settings.allowCustomBind
? (status?.listenAddress || settings.listenAddress)
: "127.0.0.1";
const advertisedHost = status?.clientHost ?? settings.clientHost;
const url = status
? (status.url || "")
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
const showClientHost = settings.allowCustomBind && (
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
);
const token = status?.bearerToken || "";
const listening = Boolean(status?.listening);
const statusLabel = listening
? t("ai.externalMcp.http.listening")
: t("ai.externalMcp.http.notListening");
const statusClass = status?.errorCode
? "text-destructive"
: listening
? "text-emerald-500"
: "text-muted-foreground";
let errorText = "";
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
else if (status?.errorCode === "port_in_use") {
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
const commitPort = () => {
const port = parsePort(portDraft);
if (port == null) {
setPortError(true);
return;
}
setPortError(false);
setPortDraft(String(port));
if (port !== settings.port) onChange({ ...settings, port });
};
const commitClientHost = () => {
const clientHost = clientHostDraft.trim();
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
setClientHostError(true);
return;
}
setClientHostError(false);
setClientHostDraft(clientHost);
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
};
const commitAddress = () => {
const address = addressDraft.trim();
if (!isIpAddress(address)) {
setAddressError(true);
return;
}
setAddressError(false);
setAddressDraft(address);
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
</div>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-http"
label={t("ai.externalMcp.http.enable")}
>
<Toggle
checked={settings.enabled}
onChange={(enabled) => onChange({ ...settings, enabled })}
ariaLabel={t("ai.externalMcp.http.enable")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.port")}
description={t("ai.externalMcp.http.portHint")}
>
<input
type="number"
aria-label={t("ai.externalMcp.http.port")}
min={1024}
max={65535}
value={portDraft}
onChange={(event) => {
setPortDraft(event.currentTarget.value);
setPortError(false);
}}
onBlur={commitPort}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.allowCustomBind")}
description={t("ai.externalMcp.http.allowCustomBind.description")}
>
<Toggle
checked={settings.allowCustomBind}
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
/>
</SettingRow>
{settings.allowCustomBind ? (
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
<input
type="text"
aria-label={t("ai.externalMcp.http.listenAddress")}
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
value={addressDraft}
onChange={(event) => {
setAddressDraft(event.currentTarget.value);
setAddressError(false);
}}
onBlur={commitAddress}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
{showClientHost ? (
<SettingRow
label={t("ai.externalMcp.http.clientHost")}
description={t("ai.externalMcp.http.clientHost.description")}
>
<input
type="text"
aria-label={t("ai.externalMcp.http.clientHost")}
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
value={clientHostDraft}
onChange={(event) => {
setClientHostDraft(event.currentTarget.value);
setClientHostError(false);
}}
onBlur={commitClientHost}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
</SettingCard>
{settings.allowCustomBind ? (
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
) : null}
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
{settings.enabled && !externalMcpEnabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
) : null}
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.http.url")}
value={url}
copyKey="http-url"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.token")}
value={token}
copyKey="http-token"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={!externalMcpEnabled || !settings.enabled || rotating}
onClick={onRotateToken}
>
{t("ai.externalMcp.http.regenerate")}
</Button>
</div>
<CopyableCodeBlock
label={t("ai.externalMcp.http.codexSnippet")}
value={buildCodexHttpToml(url)}
copyKey="http-codex"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.envLine")}
value={token ? buildCodexHttpEnvLine(token) : ""}
copyKey="http-env"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.claudeCommand")}
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
copyKey="http-claude"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.genericSnippet")}
value={token ? buildGenericHttpMcpJson(url, token) : ""}
copyKey="http-json"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</div>
</div>
);
};
+6
View File
@@ -177,6 +177,12 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
rotateHttpToken?: boolean;
}) => Promise<Record<string, unknown>>;
externalMcpGetUniversalSetupPrompt?: () => Promise<{
ok: boolean;
+61 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { detectPrompt, getAlignedPrompt, isProgramInputPromptText } from "./autocomplete/promptDetector.ts";
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
@@ -817,3 +817,63 @@ test("CMD path prompts with CJK directories do not absorb padding into echoed Ch
"zh",
);
});
test("program input prompts expose snippet text and stay out of shell history (#3622)", () => {
const line = "Email: use";
const term = createFakeTerm(line, line.length);
const snippet = { id: "mail", label: "邮箱", command: "user@example.com", noAutoRun: true };
assert.equal(detectPrompt(term as never).isAtPrompt, false);
const echoed = getAlignedPrompt(term as never, "use", true);
assert.equal(echoed.prompt.isAtPrompt, true);
assert.equal(echoed.prompt.promptText, "Email: ");
assert.equal(echoed.prompt.userInput, "use");
assert.equal(echoed.alignedTyped, null);
assert.equal(resolveAutocompleteQueryInput(echoed.prompt, "use", true), "use");
assert.equal(
getSnippetSuggestions(echoed.prompt.userInput, [snippet as never])[0]?.snippet?.command,
"user@example.com",
);
assert.equal(getCommandToRecordOnEnter(echoed.prompt, echoed.alignedTyped, "use", true), null);
const pending = getAlignedPrompt(createFakeTerm("Email: ", "Email: ".length) as never, "use", true);
assert.equal(pending.prompt.promptText, "Email: ");
assert.equal(resolveAutocompleteQueryInput(pending.prompt, "use", true), "use");
assert.equal(pending.alignedTyped, null);
const shell = "user@host:~$ git pull use";
const shellPrompt = getAlignedPrompt(createFakeTerm(shell, shell.length) as never, "git pull use", true);
assert.equal(shellPrompt.prompt.promptText, "user@host:~$ ");
assert.equal(shellPrompt.prompt.userInput, "git pull use");
assert.equal(shellPrompt.alignedTyped, "git pull use");
});
test("a colon shell prompt is not a program input prompt (#3622)", () => {
for (const prompt of ["dev: ", "development: ", "PS1: "]) {
assert.equal(isProgramInputPromptText(prompt), false, prompt);
const line = `${prompt}ls`;
const term = createFakeTerm(line, line.length);
const aligned = getAlignedPrompt(term as never, "ls", true);
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
assert.equal(aligned.prompt.promptText, prompt, prompt);
assert.equal(aligned.prompt.userInput, "ls", prompt);
assert.equal(
getCommandToRecordOnEnter(aligned.prompt, aligned.alignedTyped, "ls", true),
"ls",
prompt,
);
}
assert.equal(isProgramInputPromptText("Email: "), true);
assert.equal(isProgramInputPromptText("Enter your username: "), true);
assert.equal(isProgramInputPromptText("用户名: "), true);
});
test("password, passphrase, PIN, and secret prompts stay uncompletable (#3622)", () => {
for (const line of ["Password: use", "Passphrase: use", "PIN: use", "Secret: use", "Enter secret: use"]) {
const result = getAlignedPrompt(createFakeTerm(line, line.length) as never, "use", true);
assert.equal(result.prompt.isAtPrompt, false, line);
assert.equal(resolveAutocompleteQueryInput(result.prompt, "use", true), null, line);
}
});
+4 -1
View File
@@ -7,6 +7,7 @@ import {
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
import { useI18n } from "../../application/i18n/I18nProvider";
import { usePaneVisible } from "./paneVisibilityStore";
import { getWindowPluginTerminalProviderRegistry } from "../../application/state/pluginTerminalProviderRegistry";
import { provideTerminalCompletions } from "./autocomplete/terminalCompletionProviders";
@@ -95,6 +96,7 @@ export function TerminalAutocomplete({
// flow through (and re-render) the TerminalView ctx. Popup / standalone
// Terminal mounts never publish the store — fall back to the isVisible prop
// (same contract as hibernate).
const { resolvedLocale } = useI18n();
const visible = usePaneVisible(sessionId, isVisible);
const provideCompletions = useCallback(async (
input: string,
@@ -131,8 +133,9 @@ export function TerminalAutocomplete({
historyScope: options.historyScope ?? settings?.historyScope,
signal: options.signal,
onLatePathSuggestions: options.onLatePathSuggestions,
locale: resolvedLocale,
});
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, resolvedLocale, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
const autocomplete = useTerminalAutocomplete({
termRef,
containerRef,
@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { GpuDeviceStats } from "../../application/state/useServerStats.ts";
import { TerminalGpuCards } from "./TerminalGpuCards.tsx";
function renderCards(gpus: GpuDeviceStats[], gpu = 40) {
return renderToStaticMarkup(
<I18nProvider locale="en">
<TerminalGpuCards
gpus={gpus}
gpu={gpu}
gpuName="NVIDIA GeForce RTX 4090"
gpuMemUsed={2048}
gpuMemTotal={24576}
/>
</I18nProvider>,
);
}
const card = (index: number, utilization: number | null, name = `NVIDIA A${index}`): GpuDeviceStats => ({
index,
name,
utilization,
memoryUsed: 1024 * (index + 1),
memoryTotal: 8192,
});
test("one GPU uses the same per-core bar, colors, and spacing", () => {
const markup = renderCards([card(0, 73, "NVIDIA GeForce RTX 4090")], 73);
assert.match(markup, /GPU Device Usage/);
assert.match(markup, /GPU 0/);
assert.match(markup, /NVIDIA GeForce RTX 4090/);
assert.match(markup, /73%/);
assert.match(markup, /1\.0\/8\.0G/);
assert.match(markup, /grid gap-1\.5 max-h-\[260px\] overflow-y-auto/);
assert.match(markup, /min-w-\[160px\]/);
assert.match(markup, /line-clamp-2/);
assert.match(markup, /title="NVIDIA GeForce RTX 4090"/);
assert.doesNotMatch(markup, /max-w-\[88px\]/);
assert.match(markup, /h-1\.5 bg-muted rounded-full overflow-hidden/);
assert.match(markup, /bg-amber-500/);
assert.match(markup, /text-amber-400/);
assert.match(markup, /repeat\(1, 1fr\)/);
});
test("eight GPUs stay in four scrolling columns", () => {
const markup = renderCards(Array.from({ length: 8 }, (_, index) => card(index, 10 + index)));
assert.match(markup, /repeat\(4, 1fr\)/);
assert.match(markup, /max-h-\[260px\] overflow-y-auto/);
assert.match(markup, /max-w-\[26rem\]/);
assert.match(markup, /line-clamp-2/);
assert.match(markup, /title="NVIDIA A0"/);
assert.doesNotMatch(markup, /max-w-\[88px\]/);
for (let index = 0; index < 8; index += 1) {
assert.match(markup, new RegExp(`GPU ${index}`));
}
assert.match(markup, /text-emerald-400/);
assert.doesNotMatch(markup, /repeat\(8,/);
});
test("high utilization uses the CPU red threshold and unknown cards stay muted", () => {
const markup = renderCards([
card(0, 95, "NVIDIA H100"),
{ index: 3, name: "NVIDIA A100", utilization: null, memoryUsed: null, memoryTotal: null },
], 95);
assert.match(markup, /bg-red-500/);
assert.match(markup, /text-red-400/);
assert.match(markup, /95%/);
assert.match(markup, /GPU 3/);
assert.match(markup, />--</);
assert.match(markup, /repeat\(2, 1fr\)/);
});
test("a summary without per-GPU rows falls back to one aggregate bar", () => {
const markup = renderCards([], 12);
assert.match(markup, /min-w-\[160px\]/);
assert.match(markup, /h-2 bg-muted rounded-full/);
assert.match(markup, /12%/);
assert.match(markup, /2\.0\/24\.0G/);
assert.match(markup, /text-emerald-400/);
assert.doesNotMatch(markup, /grid gap-1\.5/);
});
+109
View File
@@ -0,0 +1,109 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { GpuDeviceStats } from "../../application/state/useServerStats";
import { cn } from "../../lib/utils";
export function formatGpuMemoryGib(usedMb: number | null, totalMb: number | null): string | null {
if (usedMb === null || totalMb === null || !(totalMb > 0)) return null;
return `${(usedMb / 1024).toFixed(1)}/${(totalMb / 1024).toFixed(1)}G`;
}
function usageBarClass(usage: number): string {
return usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500";
}
function usageTextClass(usage: number): string {
return usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400";
}
interface TerminalGpuCardsProps {
gpus: GpuDeviceStats[];
gpu: number;
gpuName: string | null;
gpuMemUsed: number | null;
gpuMemTotal: number | null;
}
/**
* Per-GPU details for the terminal stats hover card.
* Mirrors the CPU per-core grid: four columns, the same bar heights,
* threshold colors, and a scroll cap so many cards stay inside the popover.
*/
export const TerminalGpuCards: React.FC<TerminalGpuCardsProps> = ({
gpus,
gpu,
gpuName,
gpuMemUsed,
gpuMemTotal,
}) => {
const { t } = useI18n();
const aggregateMemory = formatGpuMemoryGib(gpuMemUsed, gpuMemTotal);
const columns = Math.min(4, gpus.length);
return (
<div className={cn("text-xs space-y-2 w-max max-w-[26rem]", gpus.length === 1 && "min-w-[160px]")}>
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.gpuDevices")}</div>
{gpus.length > 0 ? (
<div
className="grid gap-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain"
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
>
{gpus.map((device) => {
const usage = device.utilization;
const memory = formatGpuMemoryGib(device.memoryUsed, device.memoryTotal);
return (
<div key={device.index} className="flex w-full min-w-0 flex-col items-center gap-1">
<div className="text-[10px] text-muted-foreground">
{t("terminal.serverStats.gpuIndex", { index: device.index })}
</div>
{device.name ? (
<div
className="line-clamp-2 w-full break-words text-center text-[10px] leading-tight text-muted-foreground"
title={device.name}
>
{device.name}
</div>
) : null}
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage === null ? "bg-muted" : usageBarClass(usage),
)}
style={{ width: `${usage ?? 0}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage === null ? "text-muted-foreground" : usageTextClass(usage),
)}>
{usage === null ? "--" : `${usage}%`}
</div>
{memory ? (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">{memory}</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="flex flex-col gap-1.5 min-w-[160px]">
{gpuName ? (
<div className="text-[10px] text-muted-foreground truncate" title={gpuName}>{gpuName}</div>
) : null}
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", usageBarClass(gpu))}
style={{ width: `${gpu}%` }}
/>
</div>
<div className={cn("text-center text-[11px] font-medium", usageTextClass(gpu))}>
{gpu}%{aggregateMemory ? ` · ${aggregateMemory}` : ""}
</div>
</div>
)}
</div>
);
};
@@ -28,4 +28,7 @@ test("GPU chip only renders when the remote reports GPU utilization", () => {
source,
/terminal\.serverStats\.gpu[\s\S]{0,2000}terminal\.serverStats\.memory/,
);
// Same hover card as CPU: compact aggregate trigger, per-device body.
assert.match(source, /HoverCardTrigger asChild[\s\S]*terminal\.serverStats\.gpu[\s\S]*<TerminalGpuCards/);
assert.match(source, /className="w-auto p-3"[\s\S]*side="bottom"[\s\S]*align="start"[\s\S]*sideOffset=\{8\}[\s\S]*<TerminalGpuCards/);
});
+32 -21
View File
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from '../../application/state/useServerStats';
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
import { formatGpuMemoryGib, TerminalGpuCards } from './TerminalGpuCards';
interface TerminalServerStatsProps {
sessionId: string;
@@ -48,6 +49,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
const hasNetworkDetails = serverStats.netInterfaces.length > 0;
const hasLatency = serverStats.latencyMs !== null;
const diskSummary = resolveTerminalDiskSummary(serverStats);
const gpuMemory = formatGpuMemoryGib(serverStats.gpuMemUsed, serverStats.gpuMemTotal);
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
@@ -122,28 +124,37 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
</div>
</HoverCardContent>
</HoverCard>
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi) */}
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi).
Compact chip stays aggregate, matching CPU; per-card detail is the hover card. */}
{serverStats.gpu !== null && (
<div
className="flex items-center gap-0.5 min-w-0 shrink"
aria-label={t("terminal.serverStats.gpu")}
title={
[
serverStats.gpuName,
serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
? `VRAM ${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G`
: null,
].filter(Boolean).join(' · ') || undefined
}
>
<Gpu size={10} className="flex-shrink-0" />
<span className="truncate">
{serverStats.gpu}%
{serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
? ` (${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G)`
: ''}
</span>
</div>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.gpu")}
>
<Gpu size={10} className="flex-shrink-0" />
<span className="truncate">
{serverStats.gpu}%
{gpuMemory ? ` (${gpuMemory})` : ''}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<TerminalGpuCards
gpus={serverStats.gpus}
gpu={serverStats.gpu}
gpuName={serverStats.gpuName}
gpuMemUsed={serverStats.gpuMemUsed}
gpuMemTotal={serverStats.gpuMemTotal}
/>
</HoverCardContent>
</HoverCard>
)}
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
@@ -0,0 +1,67 @@
/**
* zh-CN / zh-TW overlays for bundled Fig autocomplete descriptions.
* Each dictionary is keyed by the exact English description string.
* Spec objects stay in English; only suggestion copies shown in the popup change.
*
* The two dictionaries are separate dynamic imports so the renderer fetches
* only the chunk for the active UI locale. zh-TW is generated from zh-CN with
* OpenCC s2twp via scripts/generate-autocomplete-descriptions-zh-TW.mjs.
*/
type DescriptionDictionary = Record<string, string>;
let zhCnDictionaryPromise: Promise<DescriptionDictionary> | undefined;
let zhTwDictionaryPromise: Promise<DescriptionDictionary> | undefined;
function dictionaryFromModule(
mod: { default?: DescriptionDictionary } | DescriptionDictionary,
): DescriptionDictionary {
const loaded = mod as { default?: DescriptionDictionary };
return loaded.default ?? (mod as DescriptionDictionary);
}
function loadZhCnDescriptions(): Promise<DescriptionDictionary> {
zhCnDictionaryPromise ??= import("./descriptions.zh-CN.json").then(dictionaryFromModule);
return zhCnDictionaryPromise;
}
function loadZhTwDescriptions(): Promise<DescriptionDictionary> {
zhTwDictionaryPromise ??= import("./descriptions.zh-TW.json").then(dictionaryFromModule);
return zhTwDictionaryPromise;
}
function loadDescriptionsForLocale(locale: string): Promise<DescriptionDictionary> | undefined {
if (locale === "zh-CN") return loadZhCnDescriptions();
if (locale === "zh-TW") return loadZhTwDescriptions();
return undefined;
}
/**
* Copy spec-backed suggestions so a locale overlay cannot leak into the
* cached spec or into a later query in another locale.
*/
export async function localizeSpecSuggestionDescriptions<T extends { description?: string }>(
suggestions: readonly T[],
locale: string | undefined,
): Promise<readonly T[]> {
if (!locale || suggestions.length === 0) return suggestions;
const pending = loadDescriptionsForLocale(locale);
if (!pending) return suggestions;
let dictionary: DescriptionDictionary;
try {
dictionary = await pending;
} catch {
return suggestions;
}
let changed = false;
const localized = suggestions.map((suggestion) => {
const description = suggestion.description;
if (!description) return suggestion;
const translated = dictionary[description];
if (!translated) return suggestion;
changed = true;
return { ...suggestion, description: translated };
});
return changed ? localized : suggestions;
}
@@ -31,6 +31,7 @@ import {
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import { localizeSpecSuggestionDescriptions } from "./autocompleteDescriptionI18n";
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
@@ -272,6 +273,12 @@ export async function getCompletions(
* the shared directory cache for a later keystroke.
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
/**
* UI locale. zh-CN and zh-TW overlay bundled spec descriptions from their
* own dictionaries; any other locale keeps the original English text.
* Cached specs are not rewritten.
*/
locale?: string;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15, historyScope = "host" } = options;
@@ -368,7 +375,11 @@ export async function getCompletions(
)
: [];
for (const suggestion of specResult.suggestions) {
const specSuggestions = await localizeSpecSuggestionDescriptions(
specResult.suggestions,
options.locale,
);
for (const suggestion of specSuggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -31,10 +31,55 @@ export function isNonPromptLine(lineText: string): boolean {
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
}
const PROGRAM_INPUT_LABEL = /^(?=[\p{L}\p{N}])[\p{L}\p{N} ._'()-]{1,60}$/u;
/** Identity prompts this feature targets. A custom PS1 like `dev: ` is not one. */
const ORDINARY_INPUT_LABEL =
/^(?:e-?mail(?: address)?|user(?: ?name| ?id)?|login(?: name)?|(?:full )?name|account(?: name)?|\u90ae\u7bb1|\u7535\u5b50\u90ae\u7bb1|\u7535\u5b50\u90ae\u4ef6|\u7528\u6237\u540d|\u7528\u6237|\u59d3\u540d|\u8d26\u53f7|\u5e10\u6237|\u8d26\u6237|\u767b\u5f55\u540d)$/u;
function colonLabelText(promptText: string): string | null {
if (!promptText || promptText.length > 80) return null;
if (isSensitiveTerminalChallenge(promptText)) return null;
if (/\b(?:secret|passphrase|password|pin)\b/iu.test(promptText)) return null;
const trimmedEnd = promptText.trimEnd();
const marker = trimmedEnd.at(-1);
if (marker !== ":" && marker !== "\uFF1A") return null;
const label = trimmedEnd.slice(0, -1).trim();
if (!PROGRAM_INPUT_LABEL.test(label)) return null;
if (startsWithCommonShellCommand(label)) return null;
return label;
}
/** Colon label shape, including a custom shell PS1 (`dev: `). */
function isColonLabelPromptText(promptText: string): boolean {
return colonLabelText(promptText) !== null;
}
/**
* A program waiting for ordinary input (`read -p 'Email: '`).
* Password, passphrase, PIN, and secret prompts stay closed.
* Only the identity-label allowlist matches, so `PS1='dev: '` stays a shell prompt.
*/
export function isProgramInputPromptText(promptText: string): boolean {
const label = colonLabelText(promptText);
if (!label) return false;
const normalized = label
.replace(/\s+/g, " ")
.toLowerCase()
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
.replace(/^your\s+/u, "")
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
.trim();
return ORDINARY_INPUT_LABEL.test(normalized);
}
function isSpecificShellPromptCandidate(
promptText: string,
options: { allowGreaterThanTerminator?: boolean } = {},
): boolean {
if (isProgramInputPromptText(promptText)) return true;
const trimmed = promptText.trim();
if (
!options.allowGreaterThanTerminator &&
@@ -158,6 +203,9 @@ function inferPromptTextBeforeTypedInput(
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
}
// `Email: ` with the reply still in the keystroke buffer (echo not painted yet).
if (isProgramInputPromptText(cursorPrefix)) return cursorPrefix;
return null;
}
@@ -388,6 +436,11 @@ function alignTypedInputFromCursorPrefix(
return null;
}
// Program read-prompts are snippet queries only. Do not override a real shell
// prompt, and do not authorize Enter history recording.
const programInput = isProgramInputPromptText(promptText);
if (programInput && raw.isAtPrompt) return null;
return {
prompt: {
isAtPrompt: true,
@@ -395,7 +448,7 @@ function alignTypedInputFromCursorPrefix(
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
alignedTyped: programInput ? null : typedBuffer,
};
}
@@ -571,7 +624,9 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
// Colon read-prompts (`Email: `) stay out of shell prompt detection. Autocomplete
// opts in later via alignTypedInputFromCursorPrefix.
if (promptEnd >= 0 && !isProgramInputPromptText(lineText.substring(0, promptEnd))) {
const promptText = lineText.substring(0, promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
@@ -601,7 +656,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
const promptLineText = promptLine.translateToString(false);
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
if (pEnd >= 0 && !isProgramInputPromptText(promptLineText.substring(0, pEnd))) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
@@ -817,7 +872,24 @@ function findPromptBoundary(lineText: string): number {
}
}
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
if (lastStandardBoundary >= 0) return lastStandardBoundary;
if (lastPuaBoundary >= 0) return lastPuaBoundary;
return findProgramInputPromptBoundary(lineText);
}
function findProgramInputPromptBoundary(lineText: string): number {
const scanLimit = Math.min(lineText.trimEnd().length, 200);
let last = -1;
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
if (ch !== ":" && ch !== "\uFF1A") continue;
const next = i + 1 < lineText.length ? lineText[i + 1] : null;
if (next !== null && next !== " ") continue;
const boundary = next === " " ? i + 2 : i + 1;
if (!isColonLabelPromptText(lineText.slice(0, boundary))) continue;
last = boundary;
}
return last;
}
/**
@@ -1,10 +1,12 @@
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import {
isNonPromptLine,
isProgramInputPromptText,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { computeLivePreviewWrite } from "./livePreviewSequence";
import { parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import { computeLivePreviewWrite, isWindowsShellLineInput } from "./livePreviewSequence";
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
@@ -103,10 +105,87 @@ export function isSameAutocompleteQuery(options: {
return options.previewActive && options.previewBaseline === options.queryInput;
}
const TRUSTED_ORDINARY_INPUT_LABELS = new Set([
"email",
"e-mail",
"email address",
"e-mail address",
"username",
"user name",
"user",
"userid",
"user id",
"login",
"login name",
"name",
"full name",
"account",
"account name",
"\u90ae\u7bb1",
"\u7535\u5b50\u90ae\u7bb1",
"\u7535\u5b50\u90ae\u4ef6",
"\u7528\u6237\u540d",
"\u7528\u6237",
"\u59d3\u540d",
"\u8d26\u53f7",
"\u5e10\u6237",
"\u8d26\u6237",
"\u767b\u5f55\u540d",
]);
const UNTRUSTED_INPUT_LABEL = /(?:\b(?:secret|passphrase|password|passcode|token|key|pin|otp)\b|\u5bc6\u7801|\u53e3\u4ee4|\u4ee4\u724c|\u5bc6\u94a5|\u9a8c\u8bc1\u7801)/iu;
function trustedOrdinaryInputLabel(promptText: string): string | null {
const trimmedEnd = promptText.trimEnd();
const marker = trimmedEnd.at(-1);
if (marker !== ":" && marker !== "\uFF1A") return null;
return trimmedEnd
.slice(0, -1)
.trim()
.replace(/\s+/g, " ")
.toLowerCase()
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
.replace(/^your\s+/u, "")
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
.trim();
}
/**
* Positively identified ordinary identity prompts (`Email:`, `Username:`).
* Unknown labels stay fail-closed so they cannot bypass the sensitive latch.
*/
function isTrustedOrdinaryInputPromptText(promptText: string): boolean {
if (UNTRUSTED_INPUT_LABEL.test(promptText)) return false;
const label = trustedOrdinaryInputLabel(promptText);
if (!label || UNTRUSTED_INPUT_LABEL.test(label)) return false;
return TRUSTED_ORDINARY_INPUT_LABELS.has(label);
}
/**
* Why `sensitiveInputActive` is latched.
*
* `heuristic` is the untrusted-prompt detector (`Email:`, unknown `Label:`).
* `explicit` is authoritative metadata such as `pluginPipelineSensitiveInput`.
* Omitted means heuristic, so existing callers keep the trusted-label bypass.
*/
export type SensitiveAutocompleteLatchSource = "heuristic" | "explicit";
export type SensitiveInputActiveRef = {
current: boolean;
source?: SensitiveAutocompleteLatchSource;
};
/**
* Whether fetchSuggestions must refuse to query/render for an already-known
* sensitive line (host latch or auth-challenge prompt text).
*
* The heuristic latch is set for every untrusted input boundary, including
* safe program prompts such as `Email:`. Only a small allowlist of plain
* identity labels may bypass that latch so snippet candidates can render.
* An explicit latch (plugin pipeline or other authoritative sensitive
* metadata) stays closed for those labels too. Unknown labels, and anything
* password/token/key/PIN/OTP-like, stay closed.
*
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
* a normal shell PS1 until echo validates, so that path stays fail-closed
@@ -115,7 +194,16 @@ export function isSameAutocompleteQuery(options: {
export function shouldBlockAutocompleteForSensitivePrompt(options: {
sensitiveInputActive: boolean;
promptText: string;
sensitiveSource?: SensitiveAutocompleteLatchSource;
}): boolean {
const explicitLatch = options.sensitiveInputActive && options.sensitiveSource === "explicit";
if (
options.sensitiveInputActive
&& !explicitLatch
&& isTrustedOrdinaryInputPromptText(options.promptText)
) {
return false;
}
if (options.sensitiveInputActive) return true;
return isSensitiveTerminalChallenge(options.promptText);
}
@@ -169,6 +257,7 @@ export function getCommandToRecordOnEnter(
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
if (isProgramInputPromptText(livePrompt.promptText)) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
@@ -209,3 +298,46 @@ export function getCommandToRecordOnEnter(
return liveCommand;
}
/**
* Keystrokes that remove the text a snippet acceptance replaces.
* Line-start keeps the shell kill-line (Ctrl+U, or backspaces on Windows).
* Mid-line and program read-prompts delete only the word being typed.
* POSIX word delete sends DEL, the tty erase character. Ctrl-H is literal
* input to canonical `read` when erase is ^?, while readline still deletes
* on DEL. Windows line editing keeps backspace.
* Script snippets run automation and do not replace the line, so they keep
* the full-line clear.
*/
export function computeSnippetAcceptClearSequence(options: {
line: string | null;
os: string;
promptText: string;
snippetKind?: "snippet" | "script";
}): string {
const line = options.line ?? "";
if (line.length === 0) return "";
const { currentWord, wordIndex } = parseCommandLine(line);
const replaceWord = options.snippetKind !== "script"
&& currentWord.length > 0
&& line.endsWith(currentWord)
&& (wordIndex > 0 || isProgramInputPromptText(options.promptText));
if (replaceWord) {
const eraseChar = isWindowsShellLineInput(options.os, options.promptText) ? "\b" : "\x7f";
return eraseChar.repeat(Array.from(currentWord).length);
}
return isWindowsShellLineInput(options.os, options.promptText)
? "\b".repeat(line.length)
: "\x15";
}
/** Program read-prompts offer snippets only; shell lines keep every source. */
export function limitSuggestionsForPrompt(
promptText: string,
suggestions: CompletionSuggestion[],
): CompletionSuggestion[] {
if (!isProgramInputPromptText(promptText)) return suggestions;
return suggestions.filter((suggestion) => suggestion.source === "snippet");
}
@@ -29,6 +29,8 @@ export interface TerminalCompletionProviderRequest {
* soft budget (cache-bypassed relative SSH cwd).
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
/** UI locale forwarded to built-in spec description overlay. */
locale?: string;
}
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
@@ -74,6 +76,7 @@ export async function provideTerminalCompletions(
snippets: request.snippets,
historyScope: request.historyScope,
onLatePathSuggestions: request.onLatePathSuggestions,
locale: request.locale,
});
const pluginRequestController = new AbortController();
const abortPluginRequest = () => pluginRequestController.abort();
@@ -27,7 +27,7 @@ import {
shouldPreferRemoteShellCwd,
} from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { isWindowsShellLineInput, shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
import {
areSubDirPanelsEqual,
areSuggestionsEqual,
@@ -41,9 +41,12 @@ import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
import {
computeAutocompleteAcceptWrite,
computeSnippetAcceptClearSequence,
isSameAutocompleteQuery,
limitSuggestionsForPrompt,
resolveAutocompleteQueryInput,
shouldBlockAutocompleteForSensitivePrompt,
type SensitiveInputActiveRef,
} from "./terminalAutocompletePrompt";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
@@ -234,7 +237,7 @@ interface UseTerminalAutocompleteOptions {
* Host-owned password/auth prompt latch. When true, suppress autocomplete
* even if the PS1 still looks like a normal shell (e.g. `read -s -p '$ '`).
*/
sensitiveInputActiveRef?: RefObject<boolean>;
sensitiveInputActiveRef?: SensitiveInputActiveRef;
/** Host-owned completion Provider adapter; defaults to Netcatty's built-in Provider. */
provideCompletions?: (
input: string,
@@ -876,6 +879,7 @@ export function useTerminalAutocomplete(
if (
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: sensitiveInputActiveRef?.current === true,
sensitiveSource: sensitiveInputActiveRef?.source,
promptText: prompt.promptText,
})
) {
@@ -1004,10 +1008,11 @@ export function useTerminalAutocomplete(
};
const applyCompletions = (
completions: CompletionSuggestion[],
incomingCompletions: CompletionSuggestion[],
currentPrompt: ReturnType<typeof getAlignedPrompt>["prompt"],
options?: { preserveSelection?: boolean },
) => {
const completions = limitSuggestionsForPrompt(currentPrompt.promptText, incomingCompletions);
// Echo-lag-aware logical caret: resolved query `input` can be ahead of
// the live xterm cursor / currentPrompt.userInput. When a late path
// refresh lands while live-preview has already rewritten the shell line,
@@ -1340,8 +1345,8 @@ export function useTerminalAutocomplete(
[handleCommandHistoryCleared],
);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
/** Accept a snippet: clear the matched word (or the whole line for a script
* or a shell prompt), then run it via onAcceptSnippet. */
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
const term = termRef.current;
if (term) {
@@ -1353,10 +1358,13 @@ export function useTerminalAutocomplete(
);
if (line !== null && line.length > 0) {
if (!settingsRef.current.allowLineReplacement) return false;
const clearSequence = isWindowsShellLineInput(hostOsRef.current, prompt.promptText)
? "\b".repeat(line.length)
: "\x15"; // Ctrl+U (readline kill-line)
writeToTerminal(clearSequence);
const clearSequence = computeSnippetAcceptClearSequence({
line,
os: hostOsRef.current,
promptText: prompt.promptText,
snippetKind: snippet.kind,
});
if (clearSequence) writeToTerminal(clearSequence);
}
}
typedInputBufferRef.current = "";
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more