Compare commits

...

194 Commits

Author SHA1 Message Date
陈大猫
110e050d20 Merge pull request #708 from binaricat/feat/claude-agent-dynamic-model-probe
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: dynamically probe claude-agent-acp for available models
2026-04-13 19:55:13 +08:00
bincxz
ebcfe49ed6 fix: clear stale model cache when ACP probe returns empty
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.

Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:46:39 +08:00
bincxz
bc8ac08b9a feat: probe claude-agent-acp for available models instead of hardcoded presets
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).

This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.

Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:19 +08:00
陈大猫
309fbdbe7a Merge pull request #707 from binaricat/fix/claude-agent-independent-from-custom-provider
fix: decouple Claude agent auth from netcatty provider list
2026-04-13 19:28:24 +08:00
bincxz
11f831d820 fix: decouple Claude agent auth from netcatty provider list
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.

Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.

Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
  claude-agent-acp now resolves auth purely from its own CLI config / login
  state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
  codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
  netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
  ProviderConfig import from managedAgents.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:22:58 +08:00
陈大猫
806fb6cf29 Merge pull request #706 from binaricat/fix/issue-705-codex-independent-from-custom-provider
fix: decouple Codex agent auth from netcatty provider list (#705)
2026-04-13 19:14:08 +08:00
bincxz
cc2702b825 fix: decouple Codex agent auth from netcatty provider list (#705)
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.

The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.

Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:08:18 +08:00
陈大猫
af2589e60b Merge pull request #704 from tces1/MoreSkills
feat: add Netcatty user skills scanning and chat selection flow
2026-04-13 13:33:12 +08:00
Eric Chan
971c8a4d8b fix: harden user skills prompt injection 2026-04-13 12:49:53 +08:00
Eric Chan
59364e0c75 fix: preserve user skill selections on refresh errors 2026-04-13 12:39:33 +08:00
Eric Chan
ac83c4c27d fix: keep user skills state in sync 2026-04-13 11:15:32 +08:00
Eric Chan
aa10f962ea fix: harden user skills scanning 2026-04-13 11:08:09 +08:00
Eric Chan
1f3e531d7b Fix AI skill selection handling 2026-04-13 11:03:43 +08:00
陈大猫
ca6ca3f477 Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
[codex] finish Codex provider follow-up for #677
2026-04-13 02:34:25 +08:00
bincxz
1c9c4fcec3 fix: address second-round review feedback
- Extract fail-loud check to shared getCodexCustomConfigPreflightError so
  the list-models handler (aiBridge.cjs:2149) enforces the same up-front
  error as the stream handler. Previously a user whose config.toml
  env_key was unexported would get the targeted message on chat send but
  a generic "Missing env var" from model-list probes (once the probe was
  rewired for Codex in a future change).

- Wire Settings "Refresh Status" to also invalidate the shell-env cache.
  New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration
  now accepts an optional { refreshShellEnv } flag; the button passes it
  so a user who just exported OPENROUTER_API_KEY in their rc file can
  click Refresh instead of having to restart netcatty.

- Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts)
  so renderer TS actually sees the field instead of needing a cast.

- DRY the 360 magic number in ChatInput: extract
  MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the
  left-clamp math so the two can't drift.

- Move codexCustomConfigResolved useState declaration next to its
  companion codexConfigModel, above the effect that invokes its setter,
  and drop the duplicate declaration further down. Pure code-organization
  cleanup but removes a use-before-declaration nit.

No functional changes beyond the fail-loud parity and the refresh-shell-env
path. ACP behavior when authMethodId is omitted still requires a
real-world OpenRouter config.toml validation, which the user is running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:30:29 +08:00
bincxz
8f68e24057 fix: address review feedback on config.toml detection flow
Round of fixes driven by two parallel reviewers:

- i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal
  dollar-sign) but the replace call passed '{envKey}', so the warning
  displayed a raw "${envKey}" instead of the real env var name. Align on
  the codebase-standard {envKey} form.

- Fingerprint now folds the hash of the actual auth material (P1).
  readCodexCustomProviderConfig computes a sha256 over the hardcoded
  api_key or the resolved env_key value and returns authHash. The ACP
  provider-reuse fingerprint includes it, so rotating the key in
  ~/.zshrc + restarting netcatty (which refreshes shellEnv) now
  invalidates the cached provider instance instead of keeping the stale
  key alive. Raw value never crosses the IPC boundary — we only send
  the hex digest.

- Fail loud when config.toml's env_key isn't exported (P1). Previously
  we'd sail into spawn and let codex-acp fail mid-request with a cryptic
  "Missing environment variable". Now the stream handler rejects up
  front with a targeted error naming the missing variable and pointing
  at ~/.zshrc.

- TOML parser: basic-string escape tracking (P1). findUnquotedHash now
  tracks an explicit `escaped` flag (and only honors escapes inside
  double-quoted strings, since literal single-quoted strings don't), so
  values like "C:\\path\\" close correctly instead of consuming the
  trailing `#` as part of the string.

- TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend
  one and the first-key regex would silently fail to match, dropping
  everything before the first section header.

- Picker correctness when config.toml lacks a `model` field (P1).
  Instead of silently falling back to CODEX_MODEL_PRESETS (stock
  OpenAI IDs the user's custom endpoint can't serve), show an empty
  list so the picker disables. Track codexCustomConfigResolved so we
  distinguish "still loading" from "not a custom-config session" and
  only clear the preset list once the integration probe confirmed
  connected_custom_config.

- Logout handler isConnected also considers connected_custom_config
  (P2 consistency), matching get-integration.

- Model picker popover clamps its left position so max-w-[360px] can't
  push it past the right edge of a narrow AI side panel (P2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:21:36 +08:00
bincxz
2374f67ffc fix: skip ChatGPT auth validation when config.toml provides custom provider
On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex
request without a netcatty-managed API key. That helper spawns a fresh
codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json
to be valid — which it never is for users who only have a custom
model_provider set up in ~/.codex/config.toml. The validation failed,
the main window got "Codex ChatGPT login is stale or invalid. Reconnect
Codex in Settings" over the error channel, and the UI flipped to the
login prompt — exactly the flow the config.toml path is meant to skip.

Move readCodexCustomProviderConfig up so we compute it before the
validation gate, and only run the ChatGPT validation when there's
neither a netcatty-managed API key nor a detected config.toml custom
provider. The rest of the spawn path already omits authMethodId for
the custom-config case, so codex-acp connects directly with the shell
env and config.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:13:21 +08:00
bincxz
fea8e8b305 fix: stop probing codex-acp for models; show config.toml model when custom
Two issues the user flagged with the previous round:

1. Probing codex-acp for available models returned the stock ChatGPT
   catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active
   provider. For a user with a custom model_provider in
   ~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless
   on their endpoint. Roll back the managed-Codex probe hook and go
   back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path.

2. The fixed w-[300px] popover left empty space on the right whenever
   the longest row was narrower than 300px.

Instead of the probe, teach readCodexCustomProviderConfig to also
return the top-level `model` from config.toml and expose it on the
integration response. In AIChatSidePanel, call aiCodexGetIntegration
when Codex is the active agent and, if customConfig.model is present,
override agentModelPresets with a single-entry list pinned to that
model. Otherwise fall back to the static presets as before — so
ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while
custom-config users see just the model their provider is actually
pinned to.

Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]`
so it hugs content (great for short single-model lists) while still
capping very long rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:10:35 +08:00
bincxz
79a7e460be fix: parse model ids that contain '/' correctly in ChatInput
The picker label was being derived by splitting selectedModelId on the
first '/'. That works for Codex's ChatGPT-preset format
("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks
for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"):
selectedBaseModelId became "qwen", which doesn't match any preset, so
selectedPreset fell back to undefined and the chip displayed the
unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead
of the actually selected Codex model.

Replace the naive split with a two-step lookup: first try a direct id
match; only if that fails, look for a preset whose declared
thinkingLevels make "${preset.id}/${level}" equal to selectedModelId,
and derive the thinking segment from that. Model ids that happen to
contain '/' now round-trip correctly through the picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:05 +08:00
bincxz
f48db8ee4e fix: drop description from model picker to keep it compact
codex-acp's provider descriptions can be paragraphs ("Latest frontier
model with improvements across a wide range of capabilities..."), which
made each row of the picker feel bloated. The model id and (thinking
sub-menu's) thinking level already convey the relevant distinction —
drop the description render entirely. Keeps the dropdown tight regardless
of how verbose the upstream model catalog is.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:57 +08:00
bincxz
ba2a0389fa fix: stack model picker description below name (vertical layout)
Horizontal layout + truncate clipped too much of codex-acp's longer
descriptions ("Latest frontier model with improvements across a..." →
"Latest frontier model w..."). Reorganize each option as
checkmark | name-on-top, wrapped description below | chevron, so the
full description is readable across two lines without pushing the
popover width out. Fix popover to w-[300px] for a consistent column
width. Checkmark and chevron anchor to the first text line (self-start
with small top offset) so they stay visually aligned with the name
when the description wraps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:24 +08:00
bincxz
6309a49c37 fix: cap model picker width and truncate long descriptions
With dynamic models now pulled from codex-acp, preset descriptions can be
arbitrarily long ("Latest frontier model with improvements across a..."
from OpenAI's public model list). The popover had whitespace-nowrap on
each option and no max-w on the container, so long descriptions pushed
the dropdown off-screen.

Cap the popover at max-w-[360px], add min-w-0 + truncate to the name
span so flex children can actually shrink, and cap the description span
at max-w-[160px] with truncate so it ellipses rather than expanding the
row. ChevronRight gets shrink-0 so it can't be pushed out of view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:02:02 +08:00
bincxz
b1291d3ee2 fix: probe codex-acp for available models instead of using hardcoded preset
AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent,
so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of
OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct
for the stock ChatGPT/OpenAI path. When the user has a custom
model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.),
none of those IDs exist on their endpoint and the model picker is useless.

Extend the condition to also trigger the aiAcpListModels probe for the
Codex managed agent (detected via matchesManagedAgentConfig). The probe
launches codex-acp the same way a real session does, so it now also goes
through getCodexAuthOverride and respects the user's config.toml — and
whatever availableModels codex-acp returns (typically at least the
`model` field from config.toml) shows up in the picker. Claude keeps its
curated presets to avoid regressing that path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:00:04 +08:00
bincxz
18c001e9c5 fix: show custom config even when env_key is not exported yet
The first pass required both a custom model_provider in ~/.codex/config.toml
AND the referenced env_key to already be present in the shell environment.
If a user had the config file set up but hadn't (yet) exported the key in
their shell, detection returned null and the UI fell back to "Not
connected" + "Connect ChatGPT" — which is the exact flow they were trying
to avoid.

The config.toml is a strong enough signal of intent on its own. Keep the
integration in the connected_custom_config state regardless of env_key
availability, but expose envKeyPresent on the response so the UI can
explicitly warn "Warning: $MY_KEY is not set in your shell — export it".
Status label and color also flip to amber ("Custom config detected — env
var missing") so the state is easy to spot without dropping back to the
login prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:53:33 +08:00
bincxz
c2c6b265d4 feat: detect user's ~/.codex/config.toml custom provider as ready state
Users who hand-configure ~/.codex/config.toml with a custom model_provider
and matching [model_providers.<name>] entry are fully functional from the
Codex CLI, but netcatty only looked at codex login status — which reports
on ~/.codex/auth.json alone — and would therefore push them into the
ChatGPT login flow even though the CLI works for them.

Add a minimal TOML parser for the narrow subset we need (top-level keys
plus [model_providers.<name>] string tables), and readCodexCustomProvider
Config() to detect a usable custom-provider setup: an active model_provider
that isn't the built-in openai preset, pointing at a provider entry whose
env_key is set in the shell env (or api_key is hardcoded).

Surface this as a new integration state "connected_custom_config", add a
customConfig summary on the IPC response, and tweak the Codex settings
card so it shows the custom-provider name, hides the Connect ChatGPT
button, and drops the stale "OpenAI-compatible provider" hint when this
path is active.

At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass
authMethodId: "chatgpt" when we truly have no other option. When a
netcatty-managed API key is present we still use "codex-api-key"; when the
user has a custom config we omit authMethodId entirely so codex-acp
resolves auth from the shell env / config.toml itself. Fold the detected
custom config (provider name, base url, env key presence) into the
provider reuse fingerprint so edits to config.toml invalidate cached ACP
instances.

Fixes the Codex half of #677 for users who skip Settings → AI providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:49:23 +08:00
bincxz
1e50b66407 fix: finish Codex provider follow-up for #677 2026-04-13 01:21:05 +08:00
陈大猫
2fb2155d79 Merge pull request #701 from binaricat/feat/issue-695-preserve-buffer-on-reconnect
feat: preserve terminal buffer across reconnect (#695)
2026-04-13 01:12:01 +08:00
bincxz
3429c498f9 fix: cancel pending retry when session is closed or cancelled
Per Codex P1 on #701: the nested term.write callbacks in handleRetry
kept a captured reference to startNewSession. If the user hit Cancel or
closed the tab while those writes were still queued, cleanupSession ran
first but the callback could still fire afterwards — opening a backend
session with no owning UI (a ghost connection that nothing would tear
down).

Introduce retryTokenRef. handleRetry stamps a fresh Symbol, captures it,
and the chained callbacks verify the token (plus termRef identity) is
still current before proceeding. Invalidate the token from every path
that ends the retry intent: handleCancelConnect, handleCloseDisconnected
Session, teardown. A subsequent handleRetry naturally invalidates the
prior one by overwriting the ref, so rapid double-clicks are also safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:06:08 +08:00
bincxz
dc7b14e323 fix: delay new session start until reset sequence has flushed
Per Codex P1 on #701: term.write is asynchronous, but handleRetry was
calling sessionStarters.start* synchronously right after scheduling
the soft-reset write. On fast reconnect paths (local and serial
especially, where the backend has no network round-trip), the new
session's first output bytes can reach xterm before the \x1b[!p...\x1b[H
reset has been applied. That means the reset/home runs mid-stream of
the first prompt, repositioning the cursor or flipping modes partway
through the shell's init and producing intermittent corrupted first
screens.

Extract the protocol dispatch into startNewSession and pass it as the
callback of the second term.write, so the new session only starts
once every preparation byte (alt-screen exit, viewport preserve,
DECSTR, xterm mode disables, cursor home) has actually been applied
to the terminal state. State updates that only drive the UI overlay
(status, progress logs) stay synchronous so users see "connecting..."
immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:22 +08:00
bincxz
5d675b9cef fix: exit alt-screen before preserving viewport; use DECSTR for mode reset
Addresses two Codex findings on #701:

P1 (alt-screen ordering) — preserveTerminalViewportInScrollback only
operates on the normal buffer. If the user disconnected while inside
vim/less/top, the alt buffer was active, preserve was a no-op, and
when \x1b[?1049l later switched back to normal, the new session wrote
over still-visible pre-disconnect content instead of a cleared
viewport. Send \x1b[?1049l first, then wait for the write to flush
(via xterm's write callback) before calling preserve, so it always
runs on the normal buffer.

P2 (DECCKM / keypad / other VT220 modes) — the previous reset sequence
only disabled xterm extensions (mouse tracking, bracketed paste) and
touched SGR / cursor visibility. Full-screen apps commonly enable
DECCKM (application cursor keys) and keypad application mode; those
would leak into the new session and break arrow-key history
navigation and numeric keypad input. Use DECSTR (\x1b[!p) — soft
terminal reset — to reset DECCKM, keypad mode, SGR, insert/replace,
origin mode, and cursor visibility in one shot without clearing the
buffer. Keep explicit disables for the xterm-specific modes DECSTR
doesn't cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:42:36 +08:00
bincxz
bf9f0e1fc2 fix: reset bracketed-paste mode on reconnect
Per Codex P2 on #701: handleRetry previously removed term.reset() but
the replacement escape sequence didn't disable bracketed paste (DECSET
2004). If the disconnected session had turned it on, term.modes
.bracketedPasteMode stayed true into the next connection; the paste
and snippet paths in createXTermRuntime keep wrapping input with
\x1b[200~ ... \x1b[201~ markers. When the new session hasn't itself
enabled bracketed paste, the shell echoes those markers as literal
text and mangles pastes.

Add \x1b[?2004l to the retry reset sequence so bracketed-paste state
starts off for the new session; the new shell's init will re-enable
it normally if it wants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:34:55 +08:00
bincxz
02967d9258 fix: do not clear terminal buffer at the top of session starters
Each session starter (startSSH / startTelnet / startMosh / startLocal)
called term.clear() as its first step. In xterm.js, clear() wipes the
entire buffer including scrollback. On initial connect this is harmless
(the buffer is already empty), but on retry it undoes the viewport
preservation that handleRetry just performed — so #695 remained broken
for any protocol that went through these starters (i.e. all of them).

The clear call served no purpose: xterm mounts with an empty buffer and
nothing writes to it before the starter runs. Remove the four
try/catch(term.clear()) blocks so handleRetry's
preserveTerminalViewportInScrollback actually sticks across reconnect
on SSH reboots, telnet drops, mosh/local respawns, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:33:16 +08:00
bincxz
343176120e feat: preserve terminal buffer across reconnect (#695)
On disconnect + retry, handleRetry previously called term.reset(), which
wipes both the visible screen and the scrollback history — so users lost
every bit of context from the previous session the moment they hit
"Start Over".

Push the current viewport into scrollback via the existing
preserveTerminalViewportInScrollback utility, then explicitly disable
the modes we actually care about not leaking across sessions (mouse
tracking 1000/1002/1003/1006, alt-screen 1049, SGR attributes, hidden
cursor) and home the cursor. This keeps the full scrollback intact so
users can scroll up to read everything from before the disconnect,
while still preventing stale escape-sequence state from bleeding into
the new session.

Fixes #695

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:25:56 +08:00
陈大猫
c0b4dace87 Merge pull request #700 from binaricat/feat/issue-690-sftp-tab-toggle
feat: add setting to hide the standalone SFTP top tab (#690)
2026-04-13 00:21:20 +08:00
bincxz
b6e8d63fef fix: remove SFTP from QuickSwitcher when SFTP tab is hidden
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.

Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:13:31 +08:00
bincxz
60c07da140 fix: exclude hidden SFTP tab from keyboard tab cycling
Per Codex P1 review on #700: when showSftpTab is off, executeHotkeyAction
still built allTabs as ['vault', 'sftp', ...orderedTabs]. nextTab from
Vault would land on hidden 'sftp', the showSftpTab effect then redirected
back to 'vault', trapping tab cycling so Ctrl/Cmd+Tab could not advance
into terminal tabs. Number shortcuts (Ctrl+1..9) were also shifted, e.g.
tab 2 resolved to hidden SFTP and ping-ponged back to Vault.

Build allTabs conditionally so 'sftp' is only in the cycle when the tab
is visible. This keeps nextTab/prevTab/switchToTab consistent with what
the user sees in the top tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:05:36 +08:00
bincxz
f89afc0e05 feat: add setting to hide the standalone SFTP top tab
Adds a "Show SFTP tab" toggle in Settings → Appearance (under the
Vault section) that controls visibility of the standalone SFTP view
in the top tab bar. When disabled:

- The SFTP tab is removed from the top tab strip.
- The openSftp hotkey (Ctrl+Shift+O / ⌘⇧O) becomes a no-op.
- If the user is currently on the SFTP tab, the active tab auto-
  switches to Vaults.

The in-session SFTP side panel (opened from the terminal toolbar) is
unaffected — that is the surface users keep when they hide the
top-level tab.

Setting persists via localStorage, syncs across windows, and is
included in the cloud SyncPayload alongside the existing Vault
visibility toggles (showRecentHosts,
showOnlyUngroupedHostsInRoot). Default: on.

Addresses the first ask in #690.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:15 +08:00
陈大猫
ca0b1ed9ae Merge pull request #699 from binaricat/fix/issue-694-ctrl-f-hardcoded
fix: remove hardcoded Ctrl+F handler bypassing configurable shortcuts
2026-04-12 23:46:09 +08:00
bincxz
555438a02a fix: set Ctrl+F as the default PC shortcut for terminal search
Previously the documented default was Ctrl+Shift+F on PC, but a
hardcoded handler always captured plain Ctrl+F regardless of the
configured binding — so the effective default users experienced was
Ctrl+F. Now that the hardcoded handler is removed, align the declared
default with that historical behavior so existing users don't lose the
shortcut they were used to. Users who need plain Ctrl+F for the shell
(e.g. zsh forward-char) can remap or disable it in Settings → Shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:44:05 +08:00
bincxz
97e78624bb fix: remove hardcoded Ctrl+F handler that bypassed configurable shortcuts
The xterm custom key event handler intercepted plain Ctrl+F / Cmd+F to
open terminal search, ignoring the user's configured keybinding scheme.
This conflicted with zsh's forward-char (Ctrl+F) and gave users no way
to disable it via the Shortcuts settings tab.

The configurable keybinding system below already routes the
searchTerminal action via checkAppShortcut, with defaults of
Ctrl+Shift+F (PC) and Cmd+F (Mac). Dropping the hardcoded branch
lets the user's settings take effect. Also remove the stale
"(Ctrl+F)" label from the toolbar tooltip since the shortcut is
configurable and the default on PC is Ctrl+Shift+F.

Fixes #694

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:42:27 +08:00
陈大猫
eab1e8db67 Merge pull request #698 from binaricat/codex/issue-638-root-ungrouped-hosts
[codex] Add vault root ungrouped host filter toggle
2026-04-12 23:36:47 +08:00
bincxz
8e6392e503 persist vault root filter toggles immediately 2026-04-12 23:30:02 +08:00
bincxz
8b99f2411f fix vault root host filter sync and empty states 2026-04-12 23:27:36 +08:00
bincxz
98905b9c81 fix vault hosts section initialization order 2026-04-12 23:14:59 +08:00
bincxz
b7e1df9916 hide empty root hosts section 2026-04-12 23:13:44 +08:00
bincxz
3089cab88d add vault root ungrouped host toggle 2026-04-12 23:09:03 +08:00
Eric Chan
50b20eaa05 chore: triple-pass review and hardening of AI Skills logic 2026-04-12 17:25:45 +08:00
Eric Chan
3ab42bf588 chore: final hardening of User Skills logic and async IO 2026-04-12 17:14:49 +08:00
Eric Chan
84423a0096 fix: resolve TypeScript errors and optimize User Skills with async IO 2026-04-12 17:11:50 +08:00
陈大猫
98dda8a51b Merge pull request #693 from binaricat/fix/claude-acp-custom-model-provider
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: Claude ACP agent now uses custom API key and base URL
2026-04-12 00:51:25 +08:00
bincxz
42baa5cb78 fix: include provider base URL in ACP reuse fingerprint for Claude
The ACP provider reuse gate only computed authFingerprint for Codex,
leaving it null for Claude. Changing the configured provider or base
URL mid-session would keep reusing the stale provider instance.

Now Claude computes an authFingerprint from apiKey + baseURL, so
changing either value invalidates the cached provider and forces
recreation with the new credentials/endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:37:34 +08:00
bincxz
11fd7fcd71 fix: prefer anthropic provider over generic custom for Claude ACP
A generic custom provider (OpenAI-compatible) could be selected for
Claude, passing wrong credentials. Now we prefer an explicit anthropic
provider and only fall back to a custom provider when it has a baseURL
configured (indicating intentional Anthropic-compatible gateway use).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:31:01 +08:00
bincxz
d6950948fa fix: also inject OPENAI_BASE_URL for Codex ACP agent
Codex reads OPENAI_BASE_URL to connect to custom API endpoints.
Without this, users with a custom baseURL on their OpenAI provider
config would still hit the default api.openai.com endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:29:14 +08:00
bincxz
9693793bba fix: allow Claude ACP agent to use custom API key and base URL
The renderer only resolved OpenAI providers (for Codex) when passing
provider IDs to the main process. Claude agent was never matched, so
no API key was injected. Additionally, the main process only injected
CODEX_API_KEY — never ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL.

Changes:
- Renderer now resolves anthropic/custom provider for Claude agent,
  openai provider for Codex agent (via matchesManagedAgentConfig)
- Main process injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL into
  claude-agent-acp env when a provider is configured, across all three
  ACP provider creation paths (list-models, stream, fallback)

This enables users who configure an Anthropic provider with a custom
base URL (e.g. CC Switch proxy) to use Claude Code without being
redirected to the official OAuth flow.

Closes #677

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:26:24 +08:00
陈大猫
a72f012851 Merge pull request #692 from binaricat/fix/scrollback-zero-wheel-scroll
fix: mouse wheel scrolling broken when scrollback set to 0
2026-04-12 00:04:44 +08:00
bincxz
1368709f4e fix: map scrollback=0 to large value so mouse wheel scrolling works
xterm.js treats scrollback=0 as "no scrollback buffer", which makes
hasScrollback return false and converts wheel events into arrow-key
sequences. The UI uses 0 to mean "no limit", so map it to 999999
before passing to xterm.js.

Closes #689

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:00:18 +08:00
陈大猫
d1408b8050 Merge pull request #688 from binaricat/feat/ui-matched-terminal-themes
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add Follow Application Theme for terminal + 14 UI-matched themes
2026-04-10 22:01:59 +08:00
bincxz
9ca68561b3 fix: clean up stale inherited theme state 2026-04-10 21:49:01 +08:00
bincxz
c3c579b8a0 fix: close remaining theme sync gaps 2026-04-10 21:43:15 +08:00
bincxz
2784ecdf28 fix: sync inherited themes in editors 2026-04-10 21:30:25 +08:00
bincxz
75bbd1f300 fix: preserve theme inheritance and modal rollback 2026-04-10 21:21:51 +08:00
bincxz
4ee4ef7b60 fix: polish follow-app terminal theme UX 2026-04-10 21:03:14 +08:00
Eric Chan
58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
bincxz
32f4aadab2 fix: follow-app-theme now overrides per-host theme settings
When followAppTerminalTheme is on, all terminals should use the
UI-matched theme — but three resolution points were still checking
per-host overrides:

1. App.tsx resolveTheme() in the activeTerminalTheme computation
2. Terminal.tsx effectiveTheme computation
3. TerminalLayer.tsx focusedThemeId computation

Added followAppTerminalTheme prop flowing from App → TerminalLayer
→ Terminal. When the flag is true, per-host theme resolution is
bypassed so all terminals consistently match the app chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:25:21 +08:00
bincxz
fc32b44d8e fix: replace missing ToggleRow with SettingRow + Toggle
ToggleRow is a locally-defined component in HostDetailsPanel and
GroupDetailsPanel — it is NOT exported or available in the terminal
settings tab. Using it caused a white-screen crash. Replaced with
the existing SettingRow + Toggle pattern that's already used
throughout the terminal settings tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:19:54 +08:00
bincxz
76cd1f2883 fix: remove unused variables flagged by eslint
- App.tsx: remove unused followAppTerminalTheme/setFollowAppTerminalTheme
  from destructuring (they flow through settings object, not App props)
- createTerminalSessionStarters.ts: remove dead usedKey/usedPassword
  assignments left over from PR #680 which changed runDistroDetection
  to use the existing session's connection instead of auth credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:17:15 +08:00
bincxz
76d37d982a fix: upgrade-safe default + cross-window broadcast for follow-theme
P1: Follow mode defaulted ON when the storage key was missing, which
is true for ALL existing users after upgrade (not just fresh
installs). Now checks whether a terminal theme was already stored —
if so, this is an upgrade and we default OFF to preserve the user's
manual choice. Only genuinely fresh installs (no terminal theme in
storage) default to ON.

P2: The follow-theme persist effect now calls notifySettingsChanged
and a matching branch in the cross-window storage event handler
syncs the toggle state across windows, matching the pattern used by
all other terminal settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:14:05 +08:00
bincxz
6d2f3f28c0 feat: add "Follow Application Theme" for terminal + 14 UI-matched terminal themes (#675)
When enabled (default for new users), the terminal theme automatically
switches to match the active app UI theme — so the terminal background
blends seamlessly with the app chrome, regardless of which UI theme
preset the user picks (Snow, Midnight, Forest, etc.).

## New terminal themes (14)

Each built-in UI theme preset now has a corresponding terminal theme
with an exactly matching background color:

Light: ui-snow, ui-pure-white, ui-ivory, ui-mist, ui-mint, ui-sand,
ui-lavender — ANSI palette based on netcatty-light with per-theme
cursor colors that complement the UI accent.

Dark: ui-pure-black, ui-midnight, ui-deep-blue, ui-vscode,
ui-graphite, ui-obsidian, ui-forest — ANSI palette based on
netcatty-dark with accent-matched cursors and selections.

## "Follow Application Theme" setting

- New toggle in Settings → Terminal → Theme section
- Default ON for new users, persisted in localStorage
- When ON: terminal theme auto-derived from the active UI theme via
  a mapping table in domain/terminalAppearance.ts
- When OFF: manual theme selector shown (existing behavior)
- Switching the app between light/dark (or changing the UI theme
  preset) instantly updates the terminal theme

## Files changed (9)

- terminalThemes.ts: +14 theme definitions
- terminalAppearance.ts: UI→terminal mapping table +
  getTerminalThemeForUiTheme()
- useSettingsState.ts: followAppTerminalTheme state + persist +
  currentTerminalTheme derivation
- storageKeys.ts: new storage key
- SettingsTerminalTab.tsx: toggle UI + conditional theme selector
- SettingsPage.tsx: pass new props
- App.tsx: destructure new state
- en.ts + zh-CN.ts: 2 new i18n keys

Closes #675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:06:13 +08:00
陈大猫
a1c9f5fbd0 fix: normalize CRLF to LF when saving text files via SFTP (#681) (#687)
On Windows, the built-in text editor produces CRLF line endings.
When saved to a Linux host via SFTP, the \r characters break shell
scripts ("command not found", syntax errors) because Linux treats
\r as part of the command.

Normalize \r\n → \n in writeSftp() before writing. LF is universally
supported — even Windows 10+ notepad handles LF-only files — so this
is safe for all target platforms.

Closes #681

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:51:01 +08:00
陈大猫
ce5cb2afec feat: add Windows portable build target (#668) (#686)
Add a `portable` target alongside the existing `nsis` installer for
Windows builds. The portable version produces a single .exe that
runs without installation — just download and double-click.

The artifact is named with a `-portable-` infix to distinguish it
from the installer in the release assets.

Closes #668

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:49:32 +08:00
Eric Chan
c771979178 Add Skills + CLI mode for external agents (#599)
* Add Skills + CLI external agent workflow

* feat: add Skills + CLI transport for ACP agents

* chore: remove branch-local compatibility shims
2026-04-10 18:41:53 +08:00
陈大猫
58c651500e feat: add Gist revision history UI for vault restore (#685)
* feat: add Gist revision history UI for vault restore (#679)

Adds a "History" button on the GitHub Gist provider card in
Settings → Sync & Cloud. Clicking it opens a modal that lists all
Gist revisions (newest first) and lets the user preview and restore
any historical version with one click.

## How it works

1. The GitHub API already returns a `history` array when fetching a
   Gist (`GET /gists/{id}`). The existing `getGistHistory()` reads
   this. A new `downloadGistRevision(sha)` function fetches a
   specific revision via `GET /gists/{id}/{sha}`.

2. CloudSyncManager exposes `getGistRevisionHistory()` (metadata
   only, no decryption) and `downloadGistRevision(sha)` (decrypt
   + return payload and preview counts).

3. useCloudSync threads both methods through to the UI.

4. CloudSyncSettings renders a three-state modal:
   - **Loading**: spinner while fetching revision list
   - **Revision list**: clickable rows with SHA prefix + date,
     "Current" badge on the latest
   - **Preview**: after clicking a revision, shows entity counts
     (hosts, keys, snippets, identities) and a "Restore This
     Version" button

5. Decryption uses the current master password. If the revision
   was encrypted with a different password (user changed it since
   then), a clear error message is shown instead of a crash.

## Changes

- `GitHubAdapter.ts`: add `downloadGistRevision()` standalone
  function + `getHistory()` / `downloadRevision()` class methods
- `CloudSyncManager.ts`: add `getGistRevisionHistory()` and
  `downloadGistRevision(sha)` with decrypt + preview
- `useCloudSync.ts`: expose both methods
- `CloudSyncSettings.tsx`: add `extraActions` slot to ProviderCard,
  render "History" button on GitHub card, revision history modal
  with list → preview → restore flow
- `en.ts` + `zh-CN.ts`: 18 new i18n keys for the modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use getConnectedAdapter and lazy gist discovery for history APIs

P1: CloudSyncManager's history methods accessed this.adapters directly
instead of getConnectedAdapter(), which lazily initializes adapters.
After an app restart the adapter map is empty even though the provider
is persisted as connected, making history fail until another sync
path initializes it.

P2: GitHubAdapter.getHistory() and downloadRevision() bailed early
when gistId was missing, unlike download() which calls findSyncGist()
to lazily discover it. Users whose gist was created after initial
setup would see no revisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review on PR #685

P1: Renamed cloudSync.history.* keys to cloudSync.revisionHistory.*
to avoid duplicate key collision with the existing "Sync History"
section title.

P2: Added getGistRevisionHistory and downloadGistRevision to the
CloudSyncHook type interface so the hook contract matches reality.

P2: Simplified decrypt error handling — any error from the decrypt
path now shows the friendly "cannot decrypt" message rather than
relying on fragile substring matching.

P2: Clear historyRevisions on each handleOpenHistory call so stale
data doesn't linger under error banners on retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore correct i18n key for Sync History section title

The sed rename pass accidentally changed the Sync History panel
heading (line 1290) from cloudSync.history.title to
cloudSync.revisionHistory.title. Restored the original key so the
two sections have distinct titles. Also removed unused err parameter
in the catch block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:47:16 +08:00
陈大猫
bcf653dd2e fix: prevent empty vault from overwriting cloud data on startup (#683)
* fix: prevent empty vault from overwriting cloud data on startup (#679)

Fixes a data-loss scenario where an empty local vault (caused by an
update, storage corruption, or import failure) silently overwrites
a non-empty cloud vault on startup via auto-sync.

The root cause is a startup timing race: the debounced auto-sync
effect (3s after data change) can fire before checkRemoteVersion
(1s delay + async download) completes its remote pull. When the
local vault is empty, this pushes an empty payload to the Gist,
permanently erasing the user's data.

Four complementary fixes:

A. Empty vault push guard (useAutoSync syncNow):
   Auto-sync refuses to push a payload where hosts, keys, snippets,
   and identities are ALL empty. Manual sync from Settings is still
   allowed for the rare case where the user intentionally emptied
   everything. Prevents the most dangerous path.

B. Skip redundant post-merge push (useAutoSync checkRemoteVersion):
   After applying a three-way merge result from the remote, set
   skipNextSyncRef so the data-change effect does not immediately
   re-upload the same payload. Removes one unnecessary API call per
   startup sync.

C. Gate auto-sync on remote check completion (useAutoSync effect):
   Added remoteCheckDoneRef — the debounced auto-sync effect will
   not fire until checkRemoteVersion has completed (success or
   failure). This closes the timing window entirely: an empty vault
   can no longer race ahead of the remote pull.

D. Empty-vault-vs-cloud confirmation dialog (App.tsx + useAutoSync):
   When checkRemoteVersion detects local is empty but cloud has
   data, it pauses and shows a root-level dialog with two options:
   - "Restore from Cloud" (recommended) — applies the remote payload
   - "Keep Empty" — starts fresh with an empty vault
   The dialog blocks the sync flow via a Promise that resolves when
   the user picks an option. This gives users explicit control over
   a situation that previously happened silently behind their backs.

Also adds en + zh-CN i18n strings for the new dialog and toast
messages.

Closes #679

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address codex review on PR #683

P1-1: Unified isPayloadEffectivelyEmpty helper covering all synced
entity arrays (hosts, keys, snippets, identities, customGroups,
snippetPackages, portForwardingRules, knownHosts, groupConfigs).
Replaces the three inline checks in syncNow and checkRemoteVersion
that only covered hosts/keys/snippets/identities.

P1-2: Replaced hand-rolled overlay div with the project's existing
Dialog/DialogContent/DialogHeader/DialogFooter components. This adds
role="dialog", aria-modal, focus trap, and ESC-key dismiss for free.
Used lucide-react AlertTriangle/Download/Trash2 icons instead of
inline SVGs.

P2-1: Guard against double-resolve in resolveEmptyVaultConflict by
nulling the ref immediately on first call.

P2-2: Replaced hardcoded "N hosts, N keys, N snippets" with an i18n
key using interpolation (cloudSummary) so the count text is properly
translated in zh-CN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review on PR #683

P1: isPayloadEffectivelyEmpty now also checks the settings object.
A vault with only settings (e.g. custom theme, font size) and zero
hosts/keys/snippets is no longer treated as empty.

P1: Dialog accessibility — use hideCloseButton to remove the non-
functional close button, onEscapeKeyDown + onOpenChange prevent
dismiss (the user MUST choose an option), and wrap the description
in DialogDescription so aria-describedby is properly linked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use single-brace interpolation syntax for cloudSummary i18n key

The project's i18n system uses single-brace placeholders ({var}),
not double-brace ({{var}}). The double-brace syntax was rendering
as raw text instead of being interpolated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:20:27 +08:00
陈大猫
0caf19af7e fix: pass legacyAlgorithms to port forwarding SSH connections (#682)
* fix: pass legacyAlgorithms to port forwarding SSH connections (#678)

Port forwarding connections always used modern-only algorithms because
the legacyAlgorithms host setting was never threaded through to the
port forwarding bridge. When the jump server or target host runs an
older SSH implementation (e.g. OpenSSH 7.4) that only supports legacy
key exchange algorithms like diffie-hellman-group14-sha1, the
handshake fails with "Connection lost before handshake".

The SSH terminal path already handles this correctly via
buildAlgorithms(options.legacyAlgorithms) — the port forwarding path
was simply missing the same plumbing.

Changes:
- sshBridge.cjs: export buildAlgorithms so portForwardingBridge can
  reuse it (avoids duplicating the algorithm list)
- portForwardingBridge.cjs: destructure legacyAlgorithms from the
  payload, pass it to connectOpts.algorithms via buildAlgorithms(),
  and thread it through to connectThroughChain for jump host
  connections
- portForwardingService.ts: include host.legacyAlgorithms in the
  startPortForward bridge call

Closes #678

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add legacyAlgorithms to PortForwardOptions type contract

Per Codex review: the new legacyAlgorithms field was being passed
in the startPortForward call but was not declared in the
PortForwardOptions interface in global.d.ts, causing a TS2353 type
error in strict type-checking environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:31:52 +08:00
陈大猫
e8b9122270 feat: auto-detect network devices from SSH banner and skip stats polling (#680)
* feat: auto-detect network devices from SSH banner and skip stats polling (#674)

Fixes rapid AAA session churn reported on Cisco/HPE/similar network
devices running Netcatty. The root cause was two separate polls that
both open fresh exec channels (each counted as its own AAA session on
many network devices):

- runDistroDetection() opens a brand new SSH connection every time a
  host connects to run `cat /etc/os-release || uname -a`
- useServerStats polls `conn.exec(statsCommand)` every 5 seconds

Both commands fail on non-POSIX CLIs, but the channels still hit AAA.

This change avoids both by reading the SSH server identification
string that ssh2 already captures during the handshake
(`conn._remoteVer`). No extra network round-trips, zero additional
AAA entries.

## Changes

**sshBridge.cjs**
- Store `conn._remoteVer` on the session object at connect time as
  `session.remoteSshVersion`
- New IPC handler `netcatty:ssh:remoteInfo` (`getSessionRemoteInfo`)
  returning the captured SSH server software string

**preload.cjs / global.d.ts / useTerminalBackend.ts**
- Thread `getSessionRemoteInfo(sessionId)` through to the renderer

**domain/host.ts**
- `NETWORK_DEVICE_OPTIONS` constant listing the vendor IDs we can
  recognize (cisco, juniper, huawei, hpe, mikrotik, fortinet,
  paloalto, zyxel)
- `detectVendorFromSshVersion()` — pure function that parses an SSH
  server software string and returns a vendor ID or ''. Pattern set
  is sourced from Nmap nmap-service-probes (authoritative), the
  ssh-audit software.py reference, and vendor docs; see code
  comments for the exact matches used.
- `classifyDistroId()` returns `linux-like | network-device | other`
  so features that require a POSIX shell can gate on the result.

**createTerminalSessionStarters.ts (runDistroDetection)**
- Before running the /etc/os-release probe, call
  `getSessionRemoteInfo` on the already-connected session and feed
  the banner into `detectVendorFromSshVersion`. If the vendor maps
  to a known network device, emit the vendor ID via the existing
  `onOsDetected` callback and skip the shell probe entirely. For
  unknown or generic OpenSSH/Dropbear banners the existing behavior
  is preserved.

**Terminal.tsx**
- `isSupportedOs` now derives from `classifyDistroId(effectiveDistro)`
  combined with `host.deviceType !== 'network'`, so neither explicit
  network-device hosts nor banner-detected ones trigger the stats
  polling loop.

**useServerStats.ts**
- Add a consecutive-failure counter. After 3 consecutive failed
  polls, stop the interval for this session (reset on disconnect /
  sessionId change / settings toggle). This is the fallback for
  hosts the banner classifier cannot identify (Juniper JUNOS,
  Cisco NX-OS, Arista EOS — all present as plain `OpenSSH_*` but
  do not support the POSIX stats pipeline).

**DistroAvatar.tsx / HostDetailsPanel.tsx**
- Add 8 network-device vendor icons (Cisco, Juniper, Huawei, HPE,
  MikroTik, Fortinet, Palo Alto, ZyXEL) alongside the existing
  Linux distro icons, with brand colors. Icons sourced from Simple
  Icons (CC0) where available; HPE and ZyXEL use simple
  abbreviation placeholders.
- Network device vendors are added to the manual distro override
  dropdown so users can pin an icon even if their device has an
  exotic banner we don't auto-detect.

**i18n**
- English + Chinese labels for the new vendor options in the
  Host Details distro selector.

Closes #674

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate network-device detection on raw host.distro, not manual icon override

Per Codex review on PR #680: the stats-polling gate was passing
`host` through getEffectiveHostDistro() before classifying, which
honors the manual distro override (`distroMode: 'manual'` +
`manualDistro`). That meant a user who previously pinned an
"ubuntu" icon on a host that later gets banner-detected as Cisco
would still be classified as linux-like and keep generating the
AAA session flood #674 is meant to eliminate.

Separate display from gating:
- Display (DistroAvatar, host cards): keeps using
  getEffectiveHostDistro so users can cosmetically override the
  icon.
- Gating (useServerStats via Terminal.tsx isSupportedOs): reads
  host.distro directly — the value populated by banner detection —
  alongside the explicit host.deviceType flag. Manual icon choice
  can no longer re-enable polling on a detected network device.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard distro detection against stale session timers

Per Codex review on PR #680: runDistroDetection is scheduled on a
600ms setTimeout after connection and also makes async calls of its
own. A quick disconnect + reconnect on the same session slot could
fire the old timer against the new session, reading host B's SSH
banner via getSessionRemoteInfo and writing host B's vendor onto
host A's distro field — wrong icon and wrong stats-polling state.

Follow the same pattern already used for the startup-command timer
in this file (scheduledSessionId captured at schedule time, checked
inside the timer). Capture `id` at schedule time, bail out if
ctx.sessionRef.current no longer matches, and re-check after every
async await inside runDistroDetection so that a reconnect during
the banner fetch or the os-release probe also bails cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address local codex review on PR #680

Addresses three issues found in a local Codex review pass after the
remote reviewer gate was flaky:

## P0 — session tokens instead of sessionId for stale-timer guard

The previous guard captured `id` returned from startSSHSession and
compared against `ctx.sessionRef.current` inside the setTimeout and
the async runDistroDetection. But the renderer passes
`sessionId: ctx.sessionId` into startSSHSession (see
createTerminalSessionStarters.ts:543), meaning a tab reuses the
SAME sessionId across disconnect+reconnect. The comparison
`T1 === T1` always passed, so the guard was a no-op.

Replaced with a module-level Map<sessionId, object> that stores the
live "connection token" for each sessionId slot. Each call to
startSSH mints a fresh `{}` token and overwrites the entry. Timers
and async continuations compare their captured token against the
current map value by reference — a reconnect replaces the map entry
with a new token, so stale callbacks bail cleanly.

## P1 — run os-release probe on the existing SSH connection

The fallback /etc/os-release probe used `execCommand` which creates
a brand-new SSHClient() on every call. On network devices that
present as plain `OpenSSH_*` and fall through to this step
(JUNOS, NX-OS, EOS) it added one extra full-auth AAA session log
entry per connect, in addition to the failing stats polls.

Added `getSessionDistroInfo(sessionId)` as a new IPC handler that
runs the same probe via `session.conn.exec()` — an exec channel on
the already-open connection, no new handshake. Plumbed through
preload.cjs, global.d.ts, and useTerminalBackend.ts.
runDistroDetection uses this instead of execCommand in the fallback
path, also removing the unused auth-credentials argument (we are no
longer opening a new connection, so no credentials are needed).

## P2.1 — don't re-arm timers after giving up

After the consecutive-failure counter trips, useServerStats cleared
the interval but a subsequent effect rerun (visibility change,
settings tweak, etc.) would schedule a fresh `setTimeout` and
`setInterval` that would just call the early-return path forever.

The scheduling block now checks `givenUpRef.current` before arming
either timer. The flag is still cleared on the normal disconnect /
sessionId-change reset path so a reconnect gets a fresh attempt.

## P2.2 — drop the ambiguous IPSSH-* → cisco mapping

Nmap's `match ssh m|^SSH-([\d.]+)-IPSSH-` line is labelled as
`Cisco/3com IPSSHd` — it cannot identify a specific vendor from the
banner alone. Mapping it to `cisco` would risk showing the wrong
vendor icon on a 3Com device. Removed the rule entirely and
documented why with a code comment; users with such devices can
still use the Host Details manual distro override.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address remaining gaps from local codex follow-up review

P0 gap — delete connection token on session exit. Previously the map
entry lingered after disconnect, so a very late-firing timer could
still pass the isConnectionTokenCurrent check even though the session
no longer existed. Functionally harmless (the IPC calls would fail)
but semantically wrong. Now connectionTokensBySessionId.delete() is
called in the onSessionExit handler.

P1 new — exec channel leak on timeout in getSessionDistroInfo. The
timeout branch resolved the promise but didn't close the stream, so
a hanging remote command would leave the exec channel open until the
SSH connection itself dropped. Added a settled guard (resolve-once)
and stream.close() on timeout.

P2.1 gap — givenUpRef not reset on sessionId change. The failure
counter reset only happened in the !isConnected branch of the main
effect, so a sessionId swap while still connected (rare, but
possible if the tab reconnects without toggling connected state)
would permanently suppress polling. Added a small dedicated effect
that resets both counters when sessionId changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:02:24 +08:00
陈大猫
60071424d0 fix: prevent crash when clicking external links with no default browser (#676)
* fix: prevent crash when clicking external links with no default browser (#663)

On systems like Tiny11 where no default browser is associated with
http/https URLs, shell.openExternal() rejects with Windows error 0x483
("No application is associated..."). The main process treated that
rejection as an unhandledRejection, which the global handler re-throws
as fatal, crashing the entire app.

Root cause: windowManager.cjs used `void shell?.openExternal?.(url)`
inside a try/catch, assuming the try would cover the call. `void` only
discards the returned Promise — it does not catch async rejections,
so when openExternal rejected, the error escaped as a floating
unhandledRejection.

The IPC handler in main.cjs (`netcatty:openExternal`) also awaited
shell.openExternal() without any try/catch. Electron's ipcMain.handle
forwards rejections to the renderer over IPC, but the renderer-side
fallback called `window.open()`, which re-entered the same buggy
windowManager path — and that is where the process actually died.

Changes:
- windowManager.cjs: attach an explicit `.catch` on the openExternal
  Promise in both createExternalOnlyWindowOpenHandler and
  createAppWindowOpenHandler so rejections cannot propagate.
- main.cjs: wrap the IPC handler in try/catch and return a structured
  { success, error } result instead of throwing. This lets the
  renderer render an informative message.
- global.d.ts: update the openExternal return type to match.
- useApplicationBackend.ts: read the structured result and throw on
  failure so callers can react; drop the now-redundant window.open()
  fallback for the Electron branch (kept only for non-Electron envs).
- SettingsApplicationTab.tsx: show a friendly toast ("No default
  browser configured — please set one in system settings") when
  openExternal fails, instead of the previous silent failure.
- i18n: add en + zh-CN strings for the toast.

Closes #663

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: fall back to in-app browser window when system has no default browser

Instead of showing a toast when shell.openExternal() fails (e.g. Tiny11
with no default browser), open the URL in a minimal in-app BrowserWindow
so users can still read the linked page.

windowManager.cjs now exposes:
- openFallbackBrowser(url, opts): creates a stripped-down BrowserWindow
  that loads the URL. No preload script (remote content must never
  touch contextBridge), contextIsolation/nodeIntegration/sandbox all
  set to safe defaults, and an isolated persist:netcatty-fallback-browser
  session so cookies and storage do not leak into the main app.
  Basic Alt+Left / Alt+Right / Ctrl-or-Cmd+R shortcuts for navigation
  and reload.
- tryOpenExternalWithFallback(shell, url, opts): tries
  shell.openExternal first; on rejection, falls back to
  openFallbackBrowser. Returns { success, fallback?: "in-app-browser" }.

All three external-URL call paths now route through this helper:
- main.cjs netcatty:openExternal IPC handler
- createExternalOnlyWindowOpenHandler (popup blocker for child windows)
- createAppWindowOpenHandler (main/settings window window-open handler)

The renderer-side toast is retained as a last-resort for the rare case
that both system and in-app browsers fail (e.g. BrowserWindow creation
error). Copy updated to reflect the new behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve rejection semantics for failed external opens

Per Codex review on PR #676: returning { success, error } from
bridge.openExternal changed the contract from "reject on failure" to
"resolve with a failure object on failure", which silently broke
callers that rely on rejection to abort flows.

useCloudSync's OAuth path is the clearest example: it wraps
bridge.openExternal in a try/catch and rejects browserPromise inside
the catch. With the resolved-failure contract, that catch never fires,
so Promise.race([callbackPromise, browserPromise]) can hang
indefinitely when no browser is available.

Revert the contract:
- tryOpenExternalWithFallback resolves void on success (system browser
  or in-app fallback) and throws on total failure
- main.cjs IPC handler awaits and lets rejections propagate
- global.d.ts openExternal is Promise<void> again
- useApplicationBackend just awaits — rejections propagate naturally
- SettingsApplicationTab's existing try/catch + toast continues to
  work as before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: propagate fallback browser loadURL failures

Per Codex P2: openFallbackBrowser swallowed loadURL rejections by
attaching a .catch that only logged, so any caller using
tryOpenExternalWithFallback as a success signal saw an opened window
as success even when the page failed to load. OAuth flows would then
wait for the downstream callback timeout instead of canceling early
on malformed or unreachable URLs.

openFallbackBrowser now returns { window, loaded } where `loaded` is
the raw loadURL Promise, and tryOpenExternalWithFallback awaits it in
the fallback path. On initial load failure, the broken window is
closed and the original shell.openExternal error is re-thrown.

The internal popup handler inside the fallback window keeps its
fire-and-forget behavior (it must return synchronously) but now
explicitly catches the loaded rejection to avoid unhandledRejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:39:37 +08:00
陈大猫
51abe7da63 fix: send SSH keepalive on idle SFTP sessions to prevent NAT drop (#669) (#671)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The main openSftp() connection path was building ssh2 connect options
without setting keepaliveInterval at all, so no SSH-level keepalive
packets were sent on the SFTP channel. When the SFTP panel sits idle
(the common case while a user browses files), NAT/firewall state
tables reap the idle TCP connection after ~30-60s, causing the panel
to disconnect while the SSH terminal next to it — which has its own
keepalive config via sshBridge — stays connected. That matches the
exact symptom reported in #669.

Default to a 10s keepalive interval, matching the existing SFTP jump
host path (sftpBridge.cjs:466-467). Honor an explicitly configured
positive options.keepaliveInterval (in seconds) if one is passed in,
so the frontend can thread the user setting through later without
another bridge change.

Closes #669

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:45:51 +08:00
yuzifu
9667c03ddc fix: pin toolbar above content on KeychainManager page (#666)
* fix: pin toolbar above content on KeychainManager page

* fix: apply panel offset to outer wrapper so toolbar is not covered

The aside panel is rendered as an absolute overlay (right-0, w-[380px]),
so any container covered by the overlay needs mr-[380px] to avoid
having its right-side controls obscured. Previously only the inner
scroll area had the offset, which left the toolbar at full width —
its right-side controls (view-mode dropdown, etc.) would be covered
by the panel and become unclickable when it opened.

Move both the margin and the transition to the outer flex wrapper so
the toolbar and the scroll area shift together when the panel opens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:14:41 +08:00
陈大猫
9935eb2ed1 fix: preserve file permissions when saving edited file via SFTP (#667)
* fix: preserve file permissions when saving edited file via SFTP (#665)

ssh2-sftp-client's put() overwrites existing files with the server's
default mode (typically 0o666 after umask), so a 0o755 file edited
through the built-in text editor would silently become 0o666 after
save.

Stat the file before writing to capture its existing mode, then
chmod it back to that mode after put() completes. For new files,
stat fails and we fall through to let the server apply defaults,
preserving existing behavior for file creation.

Closes #665

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: also preserve setuid/setgid/sticky bits when restoring mode

Use 0o7777 mask instead of 0o777 so special permission bits are
preserved alongside the regular rwx bits — otherwise a 4755
executable would still be restored as 0755 after editing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:04:10 +08:00
Eric Chan
268b698a39 Follow up #640 for the Snippets page (#662)
* Update snippets page to use inline aside panels

* Fix nested host editor overflow in selector panel
2026-04-09 15:21:55 +08:00
Eric Chan
2491d1a177 Shorten MCP approval timeout (#659) 2026-04-09 09:56:19 +08:00
陈大猫
2bf2220d0b fix: open quick-add snippet modal in place instead of navigating (#657)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The previous "+" flow in ScriptsSidePanel switched the active tab to
Vault and jumped to the Snippets section, which ripped the user out
of their current terminal context — exactly what the feature was
supposed to avoid.

Replace the cross-panel navigation flow with a lightweight modal
dialog mounted at the App root:

- New component QuickAddSnippetDialog renders over everything and
  owns its own form state. Fields: label, command (multi-line), and
  package (combobox with allowCreate).
- App.tsx mounts the dialog globally and wires it to updateSnippets /
  updateSnippetPackages. No prop drilling through TerminalLayer.
- ScriptsSidePanel still dispatches the same netcatty:snippets:add
  window event; the dialog listens for it and opens in place.
- Reverted the navigateToSection / pendingSnippetAdd / openAddTrigger
  plumbing in App.tsx, VaultView, and SnippetsManager.

Advanced fields (targets, shortkey, tags) can still be set later
via the full Snippets manager. Cmd/Ctrl+Enter saves from any field.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:10:34 +08:00
陈大猫
683756324e feat: add "new snippet" button in terminal ScriptsSidePanel (#641) (#656)
* feat: add "new snippet" button in terminal ScriptsSidePanel (#641)

Previously, adding a new snippet required navigating back to the main
Snippets section from the Vault view. This adds a "+" button in the
search header of the terminal-side ScriptsSidePanel that jumps
directly into the snippet edit flow.

Flow:
- ScriptsSidePanel "+" → dispatches window event `netcatty:snippets:add`
- App.tsx listens → switches activeTab to vault, navigates to Snippets
  section, and bumps a monotonic `openSnippetAddTrigger` state
- VaultView forwards the trigger to SnippetsManager
- SnippetsManager watches the trigger and opens its add panel when
  the value changes (uses a ref to ignore unrelated remounts)

Closes #641

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: switch add-snippet flow to one-shot pending flag

Codex review pointed out a real bug with the monotonic trigger approach:
when SnippetsManager mounts for the first time with openAddTrigger already
non-zero (the common "+ clicked from terminal while not on Snippets section"
path), the last-seen-trigger ref is initialized to the current value and
the useEffect immediately returns early, so the add panel never opens.

Switch to a cleaner one-shot pending flag:
- App.tsx holds pendingSnippetAdd: boolean + handlePendingSnippetAddHandled
- VaultView forwards pendingSnippetAdd + onPendingSnippetAddHandled
- SnippetsManager opens the add panel on every transition to pendingAdd=true,
  then clears the flag via onPendingAddHandled, so subsequent renders and
  plain remounts are no-ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move useCallback above early return in ScriptsSidePanel

React's rules-of-hooks require all hooks to be called unconditionally.
The new handleAddSnippet useCallback was placed after the
`if (!isVisible) return null;` guard, which tripped eslint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:58:34 +08:00
陈大猫
80fbf0da2f feat: add data-section hooks for Custom CSS targeting (#642) (#655)
Custom CSS already exists in Settings → Appearance, but major UI
components use only Tailwind utility classes, making it hard for
users to reliably target regions in their custom styles.

This adds stable `data-section="..."` attributes on the root element
of the most commonly customized UI regions so users can write selectors
like `[data-section="snippets-panel"] { font-size: 14px !important; }`
without depending on implementation details.

Instrumented regions:
- snippets-panel (ScriptsSidePanel)
- host-details-panel (HostDetailsPanel via AsidePanel dataSection prop)
- group-details-panel (GroupDetailsPanel)
- serial-host-details-panel (SerialHostDetailsPanel)
- ai-chat-panel (AIChatSidePanel)
- vault-view / vault-sidebar / vault-main / vault-hosts-header / vault-host-list (VaultView)
- terminal-workspace / terminal-workspace-sidebar (TerminalLayer)
- top-tabs (TopTabs — also keeps existing data-top-tabs-root)

Also updated the Custom CSS description and placeholder in both
English and Chinese to list available hooks and show a working
example (snippet panel font-size override).

Closes #642

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:38:50 +08:00
陈大猫
556a14178c fix: prevent host details panel from being clipped on narrow windows (#653)
When the host details / new-host aside panel is open, narrow windows
could clip the panel content because the main area lacked min-w-0 and
the window had no minimum size.

- Add min-w-0 to the main area so flexbox can shrink the host list
  portion when the window narrows, keeping the 420px panel fully visible
- Set the BrowserWindow minWidth/minHeight to 1100x640 so the user
  cannot drag the window narrower than what the panel + sidebar +
  host list need to render comfortably
- Clamp previously saved window dimensions to the new minimum on launch
- Animate the New Host split button and the Terminal / Serial buttons
  to collapse with a 200ms transition when the host panel is open,
  freeing horizontal space and hiding controls that would be no-ops

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:55 +08:00
Eric Chan
7e566efe9c Add push-style host details panels (#649)
Refs: https://github.com/binaricat/Netcatty/issues/640
2026-04-08 16:42:32 +08:00
Eric Chan
1d2489b02c feat: support long-running AI terminal jobs (#647)
* Add background terminal jobs for long AI commands

* Bound background job output buffering

* Fix long-running terminal job polling and stop behavior

* Fix terminal job final output and stopping retention

* Wait for PTY stop confirmation before cancelling

* fix: address codex review findings in PTY job refactor

- [P1] Use last occurrence of start marker to skip echoed wrapper command,
  preventing control markers from leaking into stdout
- [P1] Add wall-clock timeout for foreground PTY execution so commands that
  print continuously still get terminated at the configured limit
- [P2] Add hard deadline for cancellation so jobs that ignore Ctrl+C are
  force-finished after 30s instead of staying stuck in "stopping" forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review findings

- [P1] Use visibleOutput for background job completion to keep offsets
  consistent with polling, preventing output loss when raw buffer
  (with ANSI codes) truncates earlier than the visible buffer
- [P2] Clarify system prompt that terminal_start requires PTY-backed
  sessions, so exec-only SSH sessions are not incorrectly routed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-3 codex review findings

- [P1] Always strip markers from visibleOutput in background job finish
  to prevent end-marker lines leaking into terminal_poll results
- [P2] Correct terminal_execute timeout guidance from ~2min to ~60s to
  match the actual default commandTimeoutMs (60000)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-4 codex review findings

- [P1] Delay session lock release when cancel is forced (process may
  still be running) to prevent sending commands into a busy shell
- [P2] Move scope validation before pendingSessionWriteApprovals so
  out-of-scope requests fail fast without blocking the write lock
- [P2] Add session scope checks to handleJobPoll and handleJobStop
  so chats that lose access cannot read output or cancel jobs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-5 codex review findings

- [P1] Strip marker lines before they enter the bounded visible buffer
  so they never occupy space or leak as partial fragments on truncation
- [P2] Never release session lock after forced cancellation since the
  previous process may still be attached to the PTY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-6 codex review findings

- [P2] Buffer incomplete marker lines across PTY chunks to prevent
  partial marker fragments from leaking into visible output
- [P1] Release session lock after 60s delay on forced cancel as
  compromise between safety and permanent lock
- [P2] Enforce session scope checks on jobPoll/jobStop for both
  dynamic (chatSessionId) and static (NETCATTY_MCP_SESSION_IDS) modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-7 codex review findings

- [P2] validateSessionScope now accepts explicit scopedSessionIds so
  static MCP scope mode is enforced for jobPoll/jobStop too
- [P2] Apply per-session execution lock to netcatty:ai:exec IPC path
  so it cannot race with active background jobs on the same session

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-8 codex review findings

- [P1] Make wall-clock timeout opt-in via enforceWallTimeout flag,
  enabled only for MCP terminal_execute path. Catty Agent's
  netcatty:ai:exec keeps the inactivity-based timeout since it has
  no terminal_start fallback for long-running streaming commands
- [P2] Always allow handleJobStop regardless of session scope so
  the per-session execution lock can always be released after
  workspace membership changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-9 codex review findings

- [P1] Enable enforceWallTimeout for netcatty:ai:exec to match the
  pre-PR behavior (hard wall-clock deadline). Without this, tail -f
  or verbose builds would hold the session lock indefinitely
- [P2] Treat explicit scopedSessionIds=[] as no access rather than
  falling through to global scope, matching handleGetContext's
  documented behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-10 codex review findings

- [P2] Add bounded startup deadline (30s) for the start marker arrival
  even when wall-clock timeout is disabled. Prevents background jobs
  from hanging indefinitely on already-chatty PTY sessions
- [P3] Use job-specific marker (not generic __NCMCP_) when stripping
  marker lines, so user output containing __NCMCP_ is preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-11 codex review findings

- [P2] Skip the 30s startup timeout for foreground execViaPty paths.
  It now applies only when maxBufferedChars > 0 (background jobs),
  so foreground commands queued behind a busy shell can wait
- [P2] Return empty stdout from getSnapshot() before the start marker
  arrives, so an early poll cannot advance nextOffset past pre-start
  PTY noise that gets discarded once the real command begins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-12 codex review findings

- [P1] Treat empty chat scopes as no access in validateSessionScope:
  if a chat has explicit scoped metadata (even []), enforce strictly
  rather than falling through to fallback/global scope
- [P2] Re-add session scope check in handleJobStop for static MCP
  clients (scopedSessionIds), while still allowing dynamic chat-scoped
  callers to always stop their own jobs even after scope changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-13 codex review findings

- [P2] getScopedJob now requires the caller to present the job's
  chatSessionId. Unscoped/static callers cannot reach into another
  chat's background jobs even if they learn the jobId
- [P2] Stop button no longer cancels terminal_start background jobs.
  They are intentionally long-running, so killing them on every
  per-response stop defeats the purpose of the feature. Cleanup on
  chat deletion (cleanupScopedMetadata) is preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-14 codex review findings

- [P1] terminal_start jobs no longer registered in activePtyExecs so
  ACP "Stop" / cancelPtyExecsForSession does not kill them. They are
  still managed via terminal_stop and the per-session execution lock
- [P1] Remove enforceWallTimeout from netcatty:ai:exec since Catty
  Agent has no terminal_start fallback for long-running commands.
  Inactivity timeout still catches genuinely hung processes
- [P2] Forced-cancelled jobs stay in "stopping" (completed=false)
  until the 60s lock grace period ends, so callers don't see the
  job as completed while the session is still locked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-15 codex review findings

- [P2] Allow netcatty/jobStop to bypass the chat-cancelled gate so
  users can stop terminal_start jobs even after ACP "Stop" was pressed
- [P2] Mark non-zero exit codes as failed (not completed) so callers
  don't have to special-case exitCode against status
- [P2] Pre-start cancel: clear startup timer in requestCancel and
  detect prompt return on preStartOutput so a queued job that gets
  cancelled resolves as "Cancelled", not "startup timed out"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-16 codex review findings

- [P2] Cap preStartOutput for background jobs at maxBufferedChars so
  noisy idle PTYs cannot accumulate megabytes before the start marker
  arrives or the startup timeout fires
- [P2] On forced cancel, immediately release the session lock and
  mark the job as cancelled. The error message clearly states that
  the process may still be running, and the caller sees completed=true
  exactly when the lock is no longer held — consistent semantics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-17 codex review findings

- [P2] Disable prompt-suffix completion fallback for background jobs.
  Long-running commands often print prompt-like text (nested shells,
  ssh, sudo -s, REPLs) and would otherwise be misdetected as completed.
  Background jobs rely strictly on the end marker
- [P2] consumeVisibleText now treats \\r as a carriage return that
  resets the current line, so progress bars (npm, docker pull, curl)
  collapse to the latest frame instead of accumulating every redraw

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-18 codex review findings

- [P2] Pre-start cancel on sessions without a tracked idle prompt now
  gets a 2s fallback to finish as Cancelled, instead of waiting the
  full forced-cancel window for an end marker that will never arrive
- [P3] Move session-scope validation before the busy-session check so
  out-of-scope callers cannot probe the existence/activity of foreign
  sessions via busy-state error messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-19 codex review findings

- [P1] Re-enable prompt-suffix completion fallback for background
  jobs but with a longer 10s delay so nested shells / REPLs have
  time to print past their initial prompt before the recheck
- [P2] Carriage returns now collapse progress redraws across PTY
  chunks: \\r is preserved through consumeVisibleText and
  applyCarriageReturns erases the trailing line of visibleOutput
  when a chunk starts with \\r. Verified with a fake PTY that
  emits "10%" then "\\r20%" then "\\r30%\\n" — final output is "30%"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-20 codex review findings

- [P1] Disable prompt-suffix completion fallback for background jobs.
  Commands that open child shells with the same prompt as the parent
  (bash, zsh, sudo -s, ssh) would otherwise be reported as completed
  while the child is still running. Background jobs rely strictly on
  the end marker, with their long timeout and explicit terminal_stop
- [P2] Track a monotonic visibleHighWatermark so polling nextOffset
  cannot move backwards across CR redraws. serializeBackgroundJob now
  returns the latest visible frame when the caller's offset has been
  passed by a redraw, instead of returning empty stdout permanently
- [P3] Buffer trailing lines that contain the constant __NCMCP_
  prefix (not just the full random marker token) so PTY chunk
  boundaries that split the marker mid-token cannot leak _E:0 noise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-21 codex review findings

- [P2] Foreground execs now also get a hard startup deadline (using
  the configured timeoutMs as the limit). Background jobs use a
  fixed 30s. Without this, an already-chatty PTY would let onData
  re-arm the inactivity timer forever before _S arrives
- [P2] finish() now uses the monotonic visibleHighWatermark for
  totalOutputChars on completion, so the final poll's nextOffset
  cannot regress relative to earlier polls after CR redraws

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-22 codex review findings

- [P2] cleanupScopedMetadata now also calls clearPendingApprovals so
  in-flight approval requests resolve immediately. Otherwise a chat
  deleted while an approval was pending would leave the per-session
  write lock held until the 5-minute approval timeout expires
- [P2] Allow netcatty/jobStop in observer mode so users can stop
  long-running terminal_start jobs that were launched before they
  switched to observer mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-23 codex review finding

- [P2] Apply \\r as a "deferred" carriage return: park the cursor at
  the start of the line but defer erasure until the next character
  arrives. This preserves the latest visible frame for commands like
  printf '10%%\\r'; sleep; printf '20%%\\r' that pause between
  redraws, while still collapsing continuous progress redraws to a
  single frame. Verified: snapshots now show '40%' and '50%' instead
  of empty stdout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-24 codex review findings

- [P1] Re-enable prompt fallback for background jobs with a 30s
  delay so commands open child shells / REPLs have time to print
  past their initial prompt before the recheck. This is the third
  time codex has flip-flopped on this — 30s is the compromise
- [P2] Pass chatSessionId to execViaChannel in handleExec so
  cancelPtyExecsForSession can interrupt SSH exec-channel commands
  scoped to the originating chat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-25 codex review finding

- [P1] Stop in-place CR collapsing in visibleOutput. The collapsed
  buffer made polling offsets non-monotonic and could drop finalized
  lines after a CR rewrite. Now visibleOutput stores raw bytes (with
  \\r dropped at consumeVisibleText to keep the buffer simple), the
  256KB cap naturally bounds progress-bar accumulation, and slice
  semantics work correctly across all redraw patterns. Consumers
  that want a "collapsed view" can post-process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-26 codex review findings

- [P2] Carriage returns are now preserved in the raw buffer and
  collapsed at serialize time in collapseCarriageReturns. This keeps
  monotonic offsets in the buffer while polled output shows the
  latest progress frame. A trailing \\r leaves existing content
  intact (deferred erasure semantics)
- [P2] netcatty/jobStop now bypasses the confirm-mode approval gate
  so a runaway terminal_start job can always be interrupted, even
  when the renderer is unavailable
- [P3] requestCancel's one-shot timers (2s pre-start, 150ms reinforce,
  30s force-finish) are now tracked and cleared in finish() so they
  cannot keep the Node event loop alive after the job has resolved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:39:21 +08:00
陈大猫
5ad3d0ce32 fix: prevent crash when codex-acp binary is not found (#648)
* fix: prevent crash when codex-acp binary is not found (#645)

When codex-acp is not installed, resolveCodexAcpBinaryPath returned the
bare binary name as a fallback. This caused createACPProvider to spawn a
non-existent process, emitting an async ENOENT error that crashed the app.

Return null instead of the bare name and guard all createACPProvider call
sites so the error is handled gracefully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: install cross-platform codex-acp binaries in CI build

macOS and Windows CI builds produce both arm64 and x64 packages, but
npm ci only installs optional dependencies for the host platform. This
means the codex-acp native binary for the other architecture is missing
from the packaged app, causing ENOENT crashes for users on the
non-host architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add --force to bypass cpu/os constraints for cross-arch install

The platform-specific codex-acp packages declare cpu/os constraints in
their package.json, so npm refuses to install the non-host-arch binary
with EBADPLATFORM. Use --force to bypass this check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:27 +08:00
bincxz
edf013164b fix: limit recently connected hosts to 6
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:47 +08:00
陈大猫
504b576e1c fix: stop deduplicating pinned/recent hosts from main host list (#632) (#636)
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.

Closes #632

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:53:46 +08:00
Leo Pan
890abd1c4c Fix/terminal clear preserve scrollback (#633)
* fixd:issure #622

* fix: use baseY instead of viewportY for active screen row count

When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use term.clear() for local clear to preserve prompt line

The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve both scrollback and prompt in local clear

term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:03:39 +08:00
陈大猫
0827dd416f fix: truncate long command text in snippet list to prevent layout overflow (#628) (#630)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover

Closes #628

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +08:00
陈大猫
24df4b6548 fix: support CSV password import and save password in keyboard-interactive auth (#629)
* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only skip formula guard for data rows, not header row

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:39:39 +08:00
陈大猫
7db4b18cce fix: add missing props destructuring in HostTreeView causing white screen (#625) (#626)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.

Closes #625

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:38:15 +08:00
陈大猫
844c55e99d fix: sync built-in editor theme with terminal theme in immersive mode (#623) (#624)
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.

- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
  them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
  a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:40 +08:00
陈大猫
778b43ceff fix: reset mouse tracking on start over to prevent escape sequence leak (#616) (#621)
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.

Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.

Closes #616

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:03:04 +08:00
陈大猫
6b2e5041d2 fix: sort default shell to top in quick switcher (#613) (#620)
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.

Closes #613

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:46 +08:00
陈大猫
1464cba6da feat: add xterm-container class for custom CSS bottom spacing (#614) (#619)
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.

Example custom CSS:
  .xterm-container { bottom: 10px !important; }

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:26 +08:00
陈大猫
d74d9e28a0 fix: split shortcut in workspace panes and host delete form freeze (#612) (#618)
* fix: split shortcut in workspace panes and host delete form freeze (#612)

Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.

Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Shift+right-click context menu and split content loss (#612)

Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.

Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard host-deletion cleanup against unsaved duplicates

The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: check previous host IDs before updating ref in deletion cleanup

Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: open context menu on first Shift+right-click

Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to first live pane when workspace focus is stale

When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: validate focusedSessionId against live workspace panes

focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:38:02 +08:00
陈大猫
32b74f4fea fix: persist sidebar appearance overrides for quick-connect hosts (#611)
* fix: persist sidebar appearance overrides for quick-connect hosts

Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:

1. onUpdateHost only updated existing entries (map), never inserted —
   change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
   undefined for quick-connect hosts — fall back to focusedHost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts

Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use primary font only in document.fonts.check to fix bold weight fallback

document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.

Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: normalize WebGL fontWeight rendering after terminal connection

Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use global settings for quick-connect host appearance changes

Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.

Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings

- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
  (not just fontWeight) so all appearance changes on ephemeral hosts
  update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
  ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:52:26 +08:00
Eric Chan
f284fb0505 Refine host group drop feedback (#617) 2026-04-03 12:15:07 +08:00
bincxz
1769edb881 fix: use existing common.save i18n key for custom shell modal button
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-04-02 14:38:20 +08:00
bincxz
a7873672c5 Revert "fix: replace native select with project Select component for shell dropdown"
This reverts commit 3261e481ee.
2026-04-02 14:36:04 +08:00
bincxz
d2fe0ecefe feat: replace inline custom shell input with modal dialog
When selecting "Custom..." from the shell dropdown, opens a modal with:
- Full-width input field for shell executable path
- Path validation feedback (valid/not found/is directory)
- Quick-pick buttons for common shell paths
- Confirm/Cancel buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:33:44 +08:00
bincxz
3261e481ee fix: replace native select with project Select component for shell dropdown
Use the same styled Select component as other Settings dropdowns for
visual consistency. Removes the unstyled native <select> element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:30:05 +08:00
陈大猫
3dfc84918b fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#608)
* fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)

Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use setIgnoreMenuShortcuts instead of preventDefault for Alt+Arrow

preventDefault in before-input-event blocks the keydown from reaching
xterm.js. Instead, use setIgnoreMenuShortcuts to disable Chromium's
built-in navigation shortcut while letting the key event pass through
to the terminal renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:27:08 +08:00
bincxz
3dc9581be6 Revert "fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)"
This reverts commit 4e7d69c9ff.
2026-04-02 14:13:06 +08:00
bincxz
4e7d69c9ff fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)
Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:06:04 +08:00
bincxz
7649243021 fix: replace font weight slider with select dropdown 2026-04-02 12:43:40 +08:00
bincxz
b770dbe6f5 fix: widen scrollbar hit area (12px track, 6px slider) for smoother dragging 2026-04-02 12:42:03 +08:00
bincxz
1e0979e441 fix: persist fontWeight in group config save, fix stale closure in font-loading effect
- Add fontWeight/fontWeightOverride to GroupDetailsPanel handleSubmit whitelist
- Add effectiveFontWeight to async font-loading effect dependency array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:21:09 +08:00
bincxz
9dbd2a5cf7 fix: use raw host for font weight save, fix bold fallback to use effective weight
- Font weight change/reset now patches the raw (un-merged) host record
  instead of writing back the merged host with group defaults baked in
- Bold font fallback uses effectiveFontWeight (per-host) instead of
  global terminalSettings.fontWeight in both update paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:12:27 +08:00
bincxz
702700d93c fix: live-sync font weight and scrollbar colors on theme/setting changes
- Font weight now updates on running terminals when slider is adjusted
  (uses per-host effectiveFontWeight instead of global terminalSettings)
- Scrollbar theme colors preserved when switching terminal themes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:54:57 +08:00
bincxz
0413e02bf0 feat: make font weight a per-host setting with override support
- Add fontWeight/fontWeightOverride to Host and GroupConfig interfaces
- Add resolve/has/clear helpers in terminalAppearance.ts
- Wire per-host font weight through TerminalLayer → ThemeSidePanel
- ThemeSidePanel shows "Use Global" button when host overrides weight
- createXTermRuntime resolves per-host font weight
- Add to INHERITABLE_KEYS for group config inheritance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:46:45 +08:00
bincxz
1cccbfe5fb fix: update renderer description text from Canvas to DOM
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:39:13 +08:00
bincxz
1c5960a054 feat: add font weight slider to terminal theme side panel
- Add range slider (100-900) in the Font tab of ThemeSidePanel
- Wire through TerminalLayer → App.tsx → useSettingsState
- Changes persist immediately via updateTerminalSetting('fontWeight')
- Display current weight value in status bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:13:37 +08:00
bincxz
2ae1219bb7 fix: make scrollbar thinner (5px) 2026-04-02 11:05:04 +08:00
bincxz
591b2ba010 fix: slim down xterm 6.0 scrollbar width to 8px with rounded corners
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:04:02 +08:00
bincxz
e26f1350f5 feat(xterm-6): add scrollbar theming and cleanup log messages
- Add scrollbar slider theme colors derived from foreground color
  (scrollbarSliderBackground/Hover/Active — new in xterm 6.0)
- Update log messages to say 'DOM' instead of 'canvas'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:01:59 +08:00
bincxz
d36fc2db1b fix: use correct unicode version name '15-graphemes' 2026-04-02 10:47:43 +08:00
bincxz
32ebc01552 feat: upgrade xterm.js to 6.0.0 with all addons
- @xterm/xterm: 5.5.0 → 6.0.0
- @xterm/addon-webgl: 0.18.0 → 0.19.0
- @xterm/addon-fit: 0.10.0 → 0.11.0
- @xterm/addon-search: 0.15.0 → 0.16.0
- @xterm/addon-serialize: 0.13.0 → 0.14.0
- @xterm/addon-web-links: 0.11.0 → 0.12.0
- Replace @xterm/addon-unicode11 with @xterm/addon-unicode-graphemes
  for more accurate CJK/emoji character width handling
- Enable rescaleOverlappingGlyphs for CJK glyph rendering compliance
- Replace 'canvas' renderer option with 'dom' (canvas removed in 6.0)
- Migrate saved 'canvas' setting to 'dom' automatically
- Fixes WebGL glyph atlas corruption causing garbled text (#5278)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:45:27 +08:00
bincxz
6f93a741ff fix: remove accent bar from pwsh icon 2026-04-02 10:26:42 +08:00
bincxz
d77b0531f6 fix: use rounded rectangle for fish shell icon 2026-04-02 10:25:02 +08:00
bincxz
0bc45417c7 fix: redesign shell icons without window chrome
Remove macOS traffic light dots and title bars from shell SVG icons.
Replace with clean, simple, iconic designs using rounded squares,
bold typography, and distinctive colors for each shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:20:25 +08:00
bincxz
fd88b3a36b chore: remove superpowers plan/spec docs from repo
These are local working documents and should not be tracked in git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:02:37 +08:00
陈大猫
6ac36be04b feat: local shell selection with auto-discovery (#605) 2026-04-02 08:59:49 +08:00
陈大猫
8ed1588fdb feat: add per-host option for Backspace sends ^H (#604)
* feat: add per-host option for Backspace sends ^H (#602)

Add backspaceSendsCtrlH option at host and group level to send ^H (0x08)
instead of DEL (0x7F) when pressing Backspace, for legacy system compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add per-host backspace behavior option (#602)

Add backspaceBehavior option at host and group level. When not configured,
xterm default behavior is preserved with zero interception. When set to
'ctrl-h', remaps DEL (0x7F) → ^H (0x08) for legacy system compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use remapped backspace byte for broadcast input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:39:04 +08:00
陈大猫
762255443b fix: deduplicate font list when local fonts overlap with built-in fonts (#586) (#603)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:10:43 +08:00
Rory Chou
fdf38b0a6a [codex] Fix SFTP editor close-tab hotkey handling (#598)
* fix sftp editor close-tab hotkey handling

* fix close-tab hotkey routing for open dialogs

* refine dialog close-tab fallback handling
2026-04-01 17:29:55 +08:00
陈大猫
be80741314 feat: custom keywords and colors in keyword highlighting (#597)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* feat: support custom keywords and colors in global keyword highlighting (#590)

Add ability to create custom keyword highlight rules in global settings
(Settings > Terminal > Keyword Highlighting):

- Per-rule enable/disable toggle for both built-in and custom rules
- Add custom rules with label, regex pattern, and color picker
- Delete custom rules (built-in rules cannot be deleted)
- Pattern validation with error feedback
- Custom rules sync across devices via cloud sync
- i18n support (en, zh-CN)

Built-in categories (Error, Warning, OK, Info, Debug, URL/IP/MAC) are
preserved and cannot be deleted, only toggled and recolored.

Closes #590

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use dialog modal for adding custom keyword highlight rules

Replace inline form with a proper modal dialog:
- Button opens dialog instead of showing inline inputs
- Dialog has label+color, regex pattern, and live preview
- Reset and Add buttons side by side in footer area
- Add common.add i18n key (en, zh-CN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ui: unify button styles in keyword highlight section

Both buttons now use ghost variant with equal flex-1 width for a
cleaner, balanced layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ui: fix keyword highlight rule list alignment

- Add placeholder spacer (w-5) for built-in rules to match delete
  button width on custom rules, keeping color pickers aligned
- Move regex pattern to second line for custom rules
- Use block+truncate for label and pattern text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ui: hide regex, show edit/delete icons after label for custom rules

- Remove regex pattern display from rule list
- Add pencil (edit) and trash (delete) icons after custom rule label,
  visible on hover
- Edit opens the same dialog pre-filled with rule data
- Dialog supports both add and edit modes with appropriate titles/buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ui: remove toggle dots, simplify edit/delete to plain icons

- Remove the red enable/disable dot button from all rules
- Replace Button wrappers with plain Lucide icons for edit/delete
  (no hover background, just cursor pointer)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve multi-pattern rules on edit, keep disabled state on reset

- Editing a custom rule now preserves patterns beyond the first one
- Reset to default colors no longer force-enables disabled rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace all patterns on edit instead of preserving hidden ones

When editing a custom rule, save only the single user-visible pattern
rather than silently keeping extra patterns the user cannot see.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve regex whitespace and multi-pattern rules on edit

- Stop trimming regex patterns on save (only trim for empty check)
- If pattern field unchanged during edit, preserve all original
  patterns so changing just label/color doesn't drop extra regexes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve additional patterns when editing custom rule

When editing, replace only the first pattern (the one shown in the
dialog) and keep any additional patterns intact to prevent data loss
for multi-pattern rules from sync or import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:05:18 +08:00
bincxz
7efb6d2adb fix: remove remaining isImmersive reference in useImmersiveMode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:57:30 +08:00
bincxz
33f8221d5c refactor: remove immersive mode toggle remnants — always enabled
Immersive mode was already hardcoded to true with a no-op setter.
Clean up all dead code:
- Remove isImmersive param from useImmersiveMode hook
- Remove immersiveMode/setImmersiveMode from useSettingsState
- Remove toggle from SettingsPage and SettingsAppearanceTab
- Remove sync read/write of immersiveMode setting
- Remove i18n keys for the removed toggle
- Simplify App.tsx conditionals

Kept: useImmersiveMode hook (core logic), CSS classes (fade overlay),
sync type field (backward compat), storage key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:49:03 +08:00
bincxz
f7eeb855aa fix: only apply terminal theme to tab bar when terminal view is active
When viewing Vault/SFTP, clear terminal theme vars from tab bar so it
uses the UI theme colors. Terminal theme is only applied when the
terminal layer is visible, or during theme sidebar preview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:11:51 +08:00
bincxz
a87a4ff09f fix: tab top accent line always reflects active terminal theme
activeTopTabsThemeId was only set when the theme sidebar was open,
causing the tab accent line to lose its terminal-derived color when
the sidebar was closed. Now it always tracks the focused terminal's
theme, with sidebar preview taking priority when open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:10:52 +08:00
bincxz
fbb6cf4dd3 fix: active tab indicator line uses --top-tabs-accent with fallback
The tab top accent line was using hsl(var(--primary)) which is only set
when the sidebar theme preview is active. Changed to use
var(--top-tabs-accent, hsl(var(--accent))) matching all other tab
elements, so the color is correct both with and without sidebar open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:09:02 +08:00
bincxz
cceae92f97 fix: add missing dependency 't' to handleSaveGroupConfig useCallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:00:02 +08:00
陈大猫
2f314c3588 feat: group configuration inheritance (#220) (#593)
* feat(i18n): add translations for group config panel

* feat(models): add GroupConfig data model, resolution logic, and encryption

Add the GroupConfig interface for group-level default settings that hosts
inherit. Includes ancestor-chain resolution (A/B/C merges from A, A/B,
A/B/C), host-level application logic, storage key, and secure field
encryption/decryption for sensitive GroupConfig fields.

Part of #220.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(state): add groupConfigs state management with encryption

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): create GroupDetailsPanel with full config editing

Side panel for editing group-level default configuration using AsidePanel.
Includes General, SSH, Telnet, Advanced, Mosh, and Appearance sections
with sub-panel navigation for Proxy, Chain, EnvVars, and Theme selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(vault): wire GroupDetailsPanel, replace rename dialog with full config panel

Replace all group rename dialog triggers with the new GroupDetailsPanel sidebar.
The hover edit button, context menu, and tree view edit callbacks now open the
full group configuration panel instead of a simple rename dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(connect): apply group config defaults at connection time

When connecting to a host, merge group-level default configuration so
hosts inherit their group's settings for auth, protocol, appearance,
and other inheritable fields. Connection logs still reference the
original host's label/hostname.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(sync): include groupConfigs in sync and export payloads

Add groupConfigs to SyncPayload, SyncableVaultData, buildSyncPayload,
and applySyncPayload so group connection defaults are preserved during
cloud sync and data import/export. Also wire groupConfigs into the
vault object in SettingsPage so it flows through to the sync payload
builder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vault): update group configs on move and delete

* feat(host-panel): show inherited group defaults as placeholders

When editing a host that belongs to a group with configuration, group
default values now appear as placeholder text in username, startup
command, and charset fields where the host doesn't have its own value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up unused imports in GroupDetailsPanel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(group-panel): add/remove protocol sections, editable parent group

- SSH and Telnet sections are now add/remove — click "Add Protocol"
  to enable, "..." menu to remove. Only enabled protocols override hosts.
- Parent Group is now editable via Combobox dropdown for quick
  group moving.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move SSH-specific fields into SSH protocol section

Startup Command, Legacy Algorithms, Proxy, Host Chaining,
Environment Variables, and Mosh are all SSH-specific and now only
visible when SSH protocol is added. Only Charset remains as a
shared field in the Advanced section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide charset and appearance when no protocol is added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: close Add Protocol dropdown after selection

Use controlled open state to explicitly close the dropdown when a
protocol is selected, preventing residual content from overlapping
the newly rendered section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: apply group defaults in TerminalLayer sessionHostsMap

Terminal component was re-reading the original host from the hosts
array by hostId, bypassing the group defaults applied in
handleConnectToHost. Now sessionHostsMap applies resolveGroupDefaults
+ applyGroupDefaults when building the host object for each session,
so Terminal sees the merged credentials/settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move Add Protocol to bottom, fix i18n for protocol/font labels

- Add Protocol button moved below Appearance section
- Added i18n keys: addProtocol, removeProtocol, fontFamily, fontSize
- All hardcoded English strings replaced with t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace font family text input with TerminalFontSelect dropdown

Use the same font selector component as settings, showing available
terminal fonts with preview. Includes "Use Global" reset button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(group-panel): match HostDetailsPanel key/certificate selection pattern

Replace the simple Combobox key selector with the same credential selection
flow used in HostDetailsPanel: a popover with Key/Certificate options,
inline combobox per type, and proper badge display with certificate icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(group-panel): add Local Key File option to credential selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(group-panel): add identityFilePaths to GroupConfig and Local Key File option

- Added identityFilePaths to GroupConfig interface and INHERITABLE_KEYS
- GroupDetailsPanel now supports Key, Certificate, and Local Key File
  credential selection, matching HostDetailsPanel's full credential flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent local key file input from overflowing panel width

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: constrain local key file input width with w-0 flex-1

Native input elements have a large default min-width. Using w-0 with
flex-1 forces the input to shrink within the flex container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to SSH Card to contain local key file input

Matches HostDetailsPanel's Card which uses overflow-hidden on the
credentials section to prevent long file paths from overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add min-w-0 to key file path row for proper text truncation

Flex children need min-w-0 for truncate to work correctly,
otherwise the text pushes the container wider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: force key file path text truncation with inline max-width calc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use fixed 320px max-width on key file path text to force truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to AsidePanelContent to prevent content overflow

The root cause was the inner div of AsidePanelContent only had
overflow-x-hidden which was being overridden by ScrollArea's viewport.
Changed to full overflow-hidden with w-full box-border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: override Radix ScrollArea viewport's display:table in AsidePanel

Radix ScrollArea Viewport wraps content in a div with
display:table and min-width:100%, causing content to expand beyond
the panel width. Override this on AsidePanelContent's ScrollArea
to use display:block and min-width:0 instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: critical issues — seed new hosts from group defaults, validate group names, fix empty import

- HostDetailsPanel: When groupDefaults has values for port/username/charset,
  new hosts start with undefined/empty so group defaults take effect via
  applyGroupDefaults() instead of being blocked by hardcoded values
- GroupDetailsPanel: Validate group name in handleSubmit to reject '/' and
  '\' characters, matching the old rename dialog behavior, with visual error
- useVaultState: Check groupConfigs !== undefined instead of truthy so that
  importing an empty array [] properly clears all group configs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: safe prefix replacement, remove dead code, extract shared resolveEffectiveHost

- Replace all .replace(oldPath, newPath) / .replace(sourcePath, newPath) with
  explicit prefix slicing (newPath + str.slice(oldPath.length)) in handleSaveGroupConfig
  and moveGroup for more robust path renaming
- Remove dead c.path === oldPath branch in finalConfigs mapping since updatedConfigs
  already contains the config with newPath
- Extract resolveEffectiveHost helper in App.tsx to deduplicate group defaults
  resolution in _handleTrayPanelConnect and handleConnectToHost

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve undefined port on save when group has port default

form.port || 22 was forcing port to 22 even when intentionally left
undefined for group inheritance. Now uses nullish coalescing and only
defaults to 22 when no group port default exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: SSH-adjacent field detection, chain host defaults, telnet inheritance, theme clear

- hasSshFields() now checks proxyConfig, hostChain, startupCommand,
  legacyAlgorithms, environmentVariables, moshEnabled, moshServerPath,
  and identityFilePaths so the SSH section auto-opens when editing
- Chain hosts in sessionChainHostsMap now get group defaults applied
  via resolveGroupDefaults + applyGroupDefaults
- Added telnetEnabled to GroupConfig interface and INHERITABLE_KEYS;
  save handler sets telnetEnabled: true when Telnet section is on
- Theme/font "Use global" clear now sets override to false instead of
  undefined, preventing parent group theme from leaking through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review round 4 — sync, SFTP, port forwarding, type safety, UX

- Scan groupConfigs in encrypted credential guard (P1 security)
- Add groupConfigs to auto-sync payload and three-way merge (P1 sync)
- Apply group defaults in SFTP connections (P1 SFTP)
- Apply group defaults in all port forwarding paths (P1 port forwarding)
- Make Host.port optional to fix unsafe type cast (P1 type safety)
- Fix port input empty → 0 instead of undefined (P2)
- Add port placeholder showing inherited value (P2)
- Mutual exclusion of group/host detail panels (P2)
- Fix sub-panel width jump 420px → 380px (P2)
- Validate duplicate group path on rename/reparent (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review round 5 — null guard, empty array inheritance, memo comparator, form reset

- Guard groupConfigs import against null payload (P1 crash)
- Validate duplicate path on moveGroup drag-drop (P2 data corruption)
- Clear empty environmentVariables to undefined for group inheritance (P1)
- Clear empty hostChain to undefined for group inheritance (P2)
- Add groupConfigs to SftpView memo comparator (P1 stale defaults)
- Add key={editingGroupPath} to GroupDetailsPanel for form reset (P1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review round 6 — copy credentials, protocol dialog use effective host

- Apply group defaults in handleCopyCredentials (P2)
- Apply group defaults in hasMultipleProtocols check (P2)
- Pass effective host to ProtocolSelectDialog (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: serialize protocol:'ssh' marker to persist SSH section in group config

- Add protocol:'ssh' as marker field in handleSubmit SSH block
- Detect protocol:'ssh' in hasSshFields() to preserve section on reopen
- Clean up protocol field in removeSsh()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:40:40 +08:00
陈大猫
84fd2c46f6 fix: resolve shell cwd for relative path autocomplete (#594) (#596)
* fix: resolve interactive shell cwd for relative path autocomplete (#594)

When `listSessionDir` receives a relative path (e.g. "."), the exec
channel defaults to the home directory instead of the interactive
shell's cwd. Prepend a cwd-resolution preamble that finds the sibling
shell process via $PPID and reads its /proc/<pid>/cwd, then cd's into
it before running `find`. Gracefully degrades to the old behavior if
resolution fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prefer prompt-based cwd over stale fallback for path autocomplete

Two bugs caused `cd ` autocomplete to show home dir instead of current dir:

1. resolveAutocompleteCwd skipped prompt cwd extraction when currentWord
   was empty (the "cd " trailing space case), always returning the stale
   fallbackCwd set at connection time.

2. chooseAutocompleteCwd discarded prompt cwd starting with "~/" in favor
   of fallbackCwd, even though the prompt cwd is more current when OSC 7
   is not supported by the remote shell.

Now: always attempt prompt extraction for empty/relative words, and prefer
prompt cwd ("~/path") over potentially stale fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:45:42 +08:00
bincxz
31dd757729 fix: adjust section header icon vertical alignment upward
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:29 +08:00
bincxz
cb79036d96 fix: vertically center section header icons with text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:11:50 +08:00
bincxz
32a208eec5 fix: allow pinned hosts to appear in Recently Connected section
Removing the !h.pinned filter from recentHosts — if user only
connects to pinned hosts, the Recent section would never appear.
Showing a host in both Pinned and Recent is acceptable since they
convey different information (favorite vs just used). Also removes
debug console.log statements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:10:09 +08:00
bincxz
6cbe1be5c5 fix: use ref for sessionById in handleSessionStatusChange
The useMemo-derived sessionById could be stale in the callback
closure, preventing lastConnectedAt from being set on connect.
Use a ref to always read the latest session map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:01:01 +08:00
陈大猫
c7ae51b952 feat: host/group management improvements (#506) (#589)
* feat(models): add pinned and lastConnectedAt fields to Host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(i18n): add translations for pinned and recently connected sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vault): add pin toggle, lastConnectedAt tracking, and computed sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(vault): render Pinned and Recently Connected sections at root level

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(vault): add pin/unpin context menus and hover edit buttons in all views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(vault): make breadcrumb a drop target for moving groups back to root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): add toggle for showing recently connected hosts section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve lint warnings for unused vars and unnecessary dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: improve pin performance and add pop-in animation

- Use ref for hosts in callbacks to avoid stale closures and
  unnecessary re-renders when hosts array changes
- Add pop-in spring animation on pinned host cards with staggered
  delay for a satisfying visual effect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix pop-in animation visibility and improve pin responsiveness

- Move @keyframes pop-in out of @layer base to global scope so inline
  styles can reference it
- Add translateY to animation for a bouncier, more satisfying feel
- Use pinnedAnimKey to force card remount on pin changes so animation
  replays each time
- Wrap onUpdateHosts in startTransition for non-blocking pin updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only animate newly pinned card, increase section spacing

- Track lastPinnedId instead of global animKey so only the newly pinned
  card gets the pop-in animation, not all existing pinned cards
- Clear animation state via onAnimationEnd for clean re-trigger
- Add mb-4 to Pinned and Recent sections for better visual separation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(vault): show pin indicator icon on pinned host cards

Small semi-transparent pin icon in top-right corner of pinned host
cards in the Hosts section (grid view only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: use solid amber/yellow pin indicator icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: tilt pin indicator icon 45 degrees

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: replace pin indicator with filled amber star on all pinned cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move lastConnectedAt tracking to App-level handleConnectToHost

Previously updating lastConnectedAt in VaultView's handleHostConnect
which could be lost during tab switches. Now tracked at the App level
where all connections are handled, ensuring the timestamp persists
regardless of UI navigation state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Codex review findings (P2 issues)

1. useStoredBoolean now syncs across same-window components via
   CustomEvent dispatch, so Settings toggle immediately updates VaultView
2. lastConnectedAt updated after connectToHost succeeds, not before
3. Pinned and Recently Connected sections now respect active search
   and tag filters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second round Codex review findings

1. Track lastConnectedAt on actual 'connected' status instead of
   session creation - handles via handleSessionStatusChange wrapper
2. Covers tray panel connections since all paths go through
   updateSessionStatus
3. Pinned/Recent cards now honor multi-select mode with checkbox
   UI instead of triggering connections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address third round Codex review findings

1. [P1] Use hostsRef in handleSessionStatusChange to avoid
   overwriting concurrent host changes with stale snapshot
2. [P2] Exclude pinned/recent hosts from main host list at root
   level to prevent duplicate cards on screen
3. [P2] Remove Pin action from tree view context menu since tree
   view has no pinned ordering/indicator support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address fourth round Codex review findings

1. [P1] Remove leftover onToggleHostPinned references in HostTreeView
   root-level component that were missed in previous cleanup
2. [P2] Add draggable + onDragStart to pinned/recent host cards so
   drag-and-drop between groups still works
3. [P3] Fix grouped view header count to exclude hosts already shown
   in pinned/recent sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use functional state update for lastConnectedAt, dedupe pinned from recent

1. [P2] Add updateHostLastConnected using setHosts(prev => ...) functional
   update pattern (same as updateHostDistro) to avoid overwriting concurrent
   host changes when multiple sessions connect simultaneously
2. [P3] Exclude pinned hosts from Recently Connected section to prevent
   duplicate cards between the two top sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wire showRecentHosts into settings sync, clear pin on duplicate

1. [P2] Add showRecentHosts to SyncPayload settings so the preference
   survives cloud sync and settings export/import
2. [P2] Clear pinned and lastConnectedAt on duplicated hosts so copies
   don't inherit pin/recent status from the original

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:55:45 +08:00
bincxz
df11beff8c fix: clear mainWindow reference on window destroy (#587)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The mainWindow variable was never cleared when the window was destroyed,
unlike settingsWindow which had a proper 'closed' handler. This caused
getMainWindow() to return a destroyed window object, preventing the
activate handler from correctly detecting the main window was gone and
creating a new one.

Fixes #587

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:16:59 +08:00
陈大猫
c14da33e5b Merge pull request #588 from binaricat/fix/settings-window-title
fix: settings window title and dock reopen behavior
2026-03-31 19:11:37 +08:00
bincxz
f1ce541885 fix: dock click opens main window instead of settings window (#587)
On macOS, when the main window is closed but the settings window is
still open, clicking the Dock icon would focus the settings window
instead of re-creating the main window.

- focusMainWindow() now explicitly finds the main window via
  getWindowManager() instead of using getAllWindows()[0]
- activate handler creates a new main window even when other
  windows (settings) are still open

Fixes #587

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:05:13 +08:00
bincxz
07e003fe43 fix: distinguish settings window title from main window
Set the settings window title to "netcatty Settings" and prevent
the HTML <title> tag from overriding it, so macOS Dock menu and
Window menu can distinguish between the two windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:02:36 +08:00
陈大猫
81f53c9a7f Merge pull request #585 from binaricat/feat/always-immersive-mode
feat: enable immersive mode permanently
2026-03-31 16:25:57 +08:00
bincxz
2d8cea2e7d fix: remove stale immersive mode sync/rehydration handlers
Address Codex review: remove references to setImmersiveModeState
in rehydration, IPC sync, and cross-window storage handlers that
would throw after the state setter was removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:37:59 +08:00
bincxz
b724cfc775 feat: enable immersive mode permanently and remove settings toggle
Immersive mode is now always on — the UI chrome automatically adapts
to match the active terminal theme. The toggle in Appearance settings
has been removed and the TerminalLayer preview logic simplified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:29:12 +08:00
bincxz
10ff2cc092 ui: increase unfocused workspace terminal opacity from 0.65 to 0.82
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:59 +08:00
bincxz
4124c03b80 fix: maintain scroll position when terminal search bar opens/closes
Re-fit terminal and restore viewport scroll position after search bar
toggle to prevent content jumping. Preserves bottom-stick behavior
and removes toolbar bottom border for cleaner appearance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:16 +08:00
bincxz
56a3994a52 fix: prevent tab indicator line color flash during theme switching
Keep top tabs theme vars applied based on focused terminal theme,
not just during sidebar preview. Prevents the color flash when
switching themes or closing the theme sidebar panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:21:14 +08:00
陈大猫
e1e730e439 Merge pull request #584 from binaricat/feat/expand-builtin-themes
feat: add 12 new built-in terminal color themes
2026-03-31 14:15:51 +08:00
bincxz
bb17647954 feat: add 12 new built-in terminal color themes
Add popular terminal themes sourced from official repos and
iTerm2-Color-Schemes:

- GitHub Dark / GitHub Light (primer/github-vscode-theme)
- Ubuntu (classic Ubuntu terminal)
- One Dark Pro (Binaryify/OneDark-Pro)
- Horizon (jolaleye/horizon-theme-vscode)
- Palenight (whizkydee/vscode-palenight-theme)
- Panda (tinkertrain/panda-syntax-vscode)
- Snazzy (sindresorhus/hyper-snazzy)
- Synthwave '84 (robb0wen/synthwave-vscode)
- Vesper (minimal dark theme)
- Kanso Dark / Kanso Light (zen-inspired)

Total built-in themes: 62 → 74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:51:03 +08:00
bincxz
56a0baebeb ui: use accent color for active tab indicator and remove toolbar border
- Active tab top line uses accent/primary color instead of foreground
- Remove terminal toolbar bottom border to reduce visual clutter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:41:01 +08:00
bincxz
d2a6c67e4e refactor: extract shared ThemeList component for theme selection UI
Unify theme item style across ThemeSelectPanel (host details) and
ThemeSelectModal (settings) with a shared ThemeList component featuring
compact swatch previews, dark/light/custom grouping, and no-rounded
selection highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:10:22 +08:00
bincxz
56f70d015d ui: optimize host details and chain panel layout
- SFTP Filename Encoding: inline layout with label and select on same row
- Linux Distribution: extract from Appearance into its own Card with Tux icon
- Chain panel: remove non-functional Add Host button, add search filter for
  available hosts, fix long hostname overflow with truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:57:17 +08:00
陈大猫
cf9f84767c Merge pull request #583 from binaricat/feat/show-transport-error-in-disconnect-dialog
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: show transport error in disconnect dialog
2026-03-31 10:41:25 +08:00
bincxz
3a862cbd0c feat: show transport error in disconnect dialog
When a session disconnects due to a transport error (e.g. "Keepalive timeout",
"ECONNRESET"), the error message is now surfaced in the disconnect dialog
instead of showing a generic "Disconnected" label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:37:42 +08:00
陈大猫
6af2a99680 Merge pull request #582 from binaricat/fix/ssh-keepalive-disabled-not-honored
fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
2026-03-31 10:32:06 +08:00
bincxz
b3d37d134a fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
When keepaliveInterval was set to 0 (the default, documented as "disabled"),
the code treated 0 as falsy and fell back to 10000ms. This caused ssh2 to
send keepalive@openssh.com global requests every 10s. Devices with non-OpenSSH
SSH implementations (e.g. NOKIA/ALCATEL) that don't reply to these requests
would have their connections terminated after ~40s (4 × 10s keepalive timeout).

Closes #581

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:27:08 +08:00
bincxz
a9e561ee51 feat: show "Waiting for remote..." during ZMODEM upload finalization
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
After all file data is written to the buffer, the progress bar shows
100% but the remote rz is still processing. Now a "finalizing" flag
is sent with the last progress event, and the UI displays "Waiting
for remote..." instead of the misleading 100% uploading state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:43:26 +08:00
bincxz
e808b1709e fix: increase ZMODEM handshake timeout from 10s to 120s
10s was too short for large files (466MB+). After sending all data,
the remote rz still needs time to read from TCP buffer and write to
disk before it can reply with ZRINIT/ZFIN. 120s accommodates slow
links and large files while still catching genuinely dead sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:38:45 +08:00
bincxz
d75b58e4d8 fix: timeout on ZMODEM handshake rejects instead of resolving
withTimeout was resolving silently after 10s, which made a stalled
xfer.end()/zsession.close() look like a successful transfer. Now it
rejects with "ZMODEM handshake timeout", so the .catch handler fires
and shows an error toast instead of a false success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:28:57 +08:00
bincxz
e2430cdcab fix: cancel sentry on all session cleanup paths + upload timeout guard
- terminalBridge: cancel zmodemSentry in telnet error/close, serial
  error/close, and cleanupAllSessions before deleting sessions
- sshBridge: cancel zmodemSentry in all 4 SSH cleanup paths (stream
  close, conn error, conn timeout, conn close)
- zmodemHelper: wrap xfer.end() and zsession.close() with 10s timeout
  to prevent indefinite hang when cancel/abort leaves internal
  zmodem.js Promises unresolved (prevents fd leak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:20:07 +08:00
bincxz
8e6ac8de10 revert: remove ZACK ignore handler (caused by SOCKS5 proxy, not protocol)
The "Unhandled header: ZACK" was triggered by a SOCKS5 proxy on the
server causing abnormal protocol behavior, not a real lrzsz issue.
The handler's condition was too broad (any active send) and could
mask genuine protocol errors. Keep ZRINIT and ZRPOS handlers which
have narrow conditions and address real scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:11:03 +08:00
bincxz
5495877e5a fix: ignore stray ZACK headers during ZMODEM upload
zmodem.js only handles ZACK in specific Send session states (after
ZSINIT, during file negotiation). Some receivers send extra ZACKs as
generic acknowledgements that arrive outside these states, causing
"Unhandled header: ZACK". Since ZACK is just an ack, ignoring it
is safe and keeps the transfer going.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:05:15 +08:00
bincxz
5078b3776e fix: use setImmediate instead of setTimeout(50) for drain wait
setTimeout(50) per chunk would cap upload speed at ~1.28MB/s because
ssh2's 32KB highWaterMark triggers backpressure on almost every 64KB
write. setImmediate yields to the I/O phase without a fixed delay,
letting TCP flush as fast as possible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:03:11 +08:00
bincxz
f5d6b8b4d8 fix: add backpressure handling to ZMODEM upload loop
Large file uploads (466MB+) could saturate the SSH/PTY write buffer
with all data sent synchronously, causing the ZEOF/ZFIN handshake
at the end to be delayed — the UI shows 100% but the transfer hangs
while TCP flushes the backlog.

- All writeToRemote callbacks now return stream.write() result
- Sentry sender tracks _needsDrain flag when write returns false
- Upload loop calls waitForDrain() which yields 50ms when backpressure
  is detected, letting TCP flush buffered writes between chunks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:58:42 +08:00
bincxz
1c560dbc16 fix: reject CLI paths that fail --version probe
In both discover and resolve-cli handlers, treat --version failure
(exception or empty output) as an invalid CLI. This catches .app
bundles, broken symlinks, and other non-executable paths that pass
the filesystem check but aren't actually usable CLI tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:48:15 +08:00
bincxz
4b8b0ed74c fix: reject .app directories in CLI path normalization
normalizeCliPathForPlatform used existsSync which returns true for
directories like /Applications/Codex.app. Added statSync.isFile()
check on non-Windows platforms so .app bundles are not mistaken for
CLI executables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:45:58 +08:00
陈大猫
308d825db7 feat: ZMODEM (lrzsz) file transfer support (#579)
* feat: add ZMODEM (lrzsz) file transfer support for terminal sessions

Adds ZMODEM protocol detection and file transfer capability to all
terminal session types (Local, SSH, Telnet, Mosh, Serial). Uses
zmodem.js library with main-process sentry pattern to intercept
binary data before string decoding, avoiding IPC pipeline changes.

- zmodemHelper.cjs: shared ZMODEM sentry with Electron dialog integration
- terminalBridge.cjs: encoding:null for PTY + sentry wrappers for all session types
- sshBridge.cjs: sentry wrapper for SSH stream data
- preload.cjs + global.d.ts: ZMODEM event IPC bridge and TypeScript types
- useZmodemTransfer.ts: React hook for ZMODEM transfer state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve charset decoding and add ZMODEM progress UI

- zmodemHelper: pass raw Buffer to onData, let callers handle decoding
- terminalBridge: use StringDecoder for telnet/serial, UTF-8 for local/mosh
- sshBridge: restore iconv decoder for SSH session charset support
- ZmodemProgressIndicator: floating progress bar with cancel button
- Terminal.tsx: wire useZmodemTransfer hook + toast notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ZMODEM listener cleanup, stream leak, and toast dedup

- preload: clean up zmodemListeners on session exit (memory leak)
- zmodemHelper: add ws.on('error') handler to close write stream on failure
- Terminal: use ref guard to prevent duplicate toast notifications

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings for ZMODEM

- cancel/consume error now send IPC event to renderer (prevents stuck UI)
- sanitize download filename with path.basename (path traversal prevention)
- add on_detect concurrency guard (deny if transfer already active)
- formatBytes: handle negative, zero, and TB+ values safely
- closeSession: cancel active ZMODEM before destroying transport

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent double-notification on cancel and stream error resilience

- Guard .then()/.catch() in promise chain: skip if cancel() already handled
- Download: add writeAborted flag to stop on_input after stream error
- Upload: pre-compute file stats to avoid O(N²) statSync calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use zsession.abort() instead of close() on dialog cancel

close() is only available on Send sessions. Calling it on a Receive
session throws, leaving the sentry's internal _zsession dangling and
causing subsequent terminal data to be consumed by the abandoned
ZMODEM session (terminal freeze). abort() is defined on the base
ZmodemSession class and properly fires session_end to reset the sentry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle ZFIN/OO mismatch as successful transfer

When sz exits over SSH, the shell prompt often arrives before the
ZMODEM "OO" end marker, causing zmodem.js to throw a protocol error.
Since ZFIN was already exchanged (= all file data transferred), treat
this specific error as a successful completion and forward the shell
prompt data back to the terminal via sentry re-consume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — UTF-8 decoder, ZFIN abort, session exit cleanup

- terminalBridge: use StringDecoder for local/mosh PTY to handle
  multi-byte UTF-8 split across buffer boundaries (prevents garbled
  CJK/emoji output)
- zmodemHelper: on ZFIN/OO success path, use _on_session_end() instead
  of abort() to avoid sending CAN (Ctrl-X) bytes to the remote shell
- useZmodemTransfer: listen to onSessionExit to reset state when the
  session dies mid-transfer (prevents stuck progress indicator)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — file collision handling and stream flush

- Download: auto-rename with (1), (2), etc. if file already exists
  in the target directory, preventing silent overwrite
- Download: wait for all write streams to finish flushing before
  resolving the session_end promise, ensuring data is on disk when
  the UI reports completion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — Windows PTY string compat and Telnet binary safety

- Local/Mosh PTY: handle string data from Windows node-pty which
  ignores encoding: null; convert to Buffer before sentry.consume()
- Telnet: bypass IAC negotiation during active ZMODEM transfer to
  preserve 0xFF bytes in binary data
- Telnet writeToRemote: escape 0xFF as 0xFF 0xFF per Telnet spec
  so ZMODEM binary data is not treated as IAC commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — Windows PTY guard, Telnet IAC, stream cleanup

- Local/Mosh: skip ZMODEM sentry on Windows where node-pty can't
  provide raw bytes; fall back to original string pipeline
- Telnet: always run IAC negotiation (even during ZMODEM) since the
  Telnet layer still escapes 0xFF as IAC IAC; the existing handler
  already correctly collapses IAC IAC → single 0xFF
- Download: destroy un-ended write streams on session_end to prevent
  hanging promises and leaked file descriptors on abort

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — early session start, progress throttle, no dup start

- Download: call zsession.start() before showing folder picker dialog
  so lrzsz doesn't time out waiting for ZRINIT
- Download: throttle progress IPC to ~10 updates/sec (100ms interval)
  to avoid overwhelming renderer on fast links
- Download: remove duplicate zsession.start() at bottom of Promise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle ZRPOS and prevent terminal flood after ZMODEM abort

- Add 500ms cooldown after ZMODEM abort: suppress residual protocol
  bytes from remote rz/sz that would otherwise flood the terminal
- Send 8x CAN (Ctrl-X) on abort/cancel/error to force remote end to
  stop transmitting even if the initial abort sequence was lost
- Handles "Unhandled header: ZRPOS" gracefully (zmodem.js doesn't
  support error recovery, so abort is the correct response)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: send Ctrl+C after abort in all cancel/error paths

Debian's rz stays attached to the TTY after receiving CAN sequences.
The cancel() path already sent Ctrl+C via scheduleRemoteInterruptAfterCancel,
but dialog-cancel and consume-error paths did not. Now all three abort
paths (dialog cancel, consume error, explicit cancel) send Ctrl+C after
150ms to ensure the remote rz/sz process exits and the shell regains control.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add interruptRemote for SSH ZMODEM sentry

Pass SSH stream.signal("INT") as interruptRemote callback so the
ZMODEM helper can send SIGINT to the remote process when cancelling
transfers, complementing the Ctrl+C byte sent via writeToRemote.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dialog-cancel abort uses module-level helper to avoid ReferenceError

sendExtraAbortBytes and writeToRemote are closure-scoped inside
createZmodemSentry, not accessible from handleUpload/handleDownload.
Extract abortRemoteProcess as a module-level function that takes
writeToRemote as a parameter, used in both dialog-cancel paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dialog cancel throws instead of returning to avoid false complete

When user dismisses the file/folder picker, handleUpload/handleDownload
now throw "Transfer cancelled" instead of returning normally. This
ensures the .catch() handler fires (sending error event) rather than
.then() (which would incorrectly send complete event).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — preserve transferType in progress events

- useZmodemTransfer: copy transferType from progress events so the
  transfer direction is preserved if renderer re-subscribes after
  the initial detect event was missed
- zmodemHelper: clean up upload loop comments (backpressure handled
  via 64KB chunks + setImmediate yield per iteration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: codex review — guard stale session cleanup, delete partial downloads

- Promise chain .then/.catch/.finally now compare currentZSession
  identity (=== zsession) instead of truthiness, preventing a new
  transfer from being clobbered by the old promise settling
- Aborted/incomplete downloads are deleted from disk on session_end
  so users don't end up with corrupt partial files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: unconditional cooldown suppression after ZMODEM abort

The previous cooldown checked if data "looks like residual ZMODEM"
which fails for sz's file content (arbitrary printable bytes). Now
cooldown unconditionally drops ALL incoming data for 2 seconds after
abort, with repeated CAN bursts to ensure the remote sz stops. This
prevents the terminal flood seen when cancelling large sz downloads
on fast connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:39:35 +08:00
陈大猫
af074c5704 Merge pull request #578 from binaricat/fix/tool-call-duplicate-and-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve tool call duplication and ordering in chat UI
2026-03-30 19:06:49 +08:00
bincxz
c60afdd8fe fix: preserve approval controls for tool calls in non-last assistant messages
When a stream error appends a new assistant message, the previous
one is no longer lastAssistantMessage. Its pending approval tool
calls were rendered as interrupted, losing approve/reject buttons.
Now they retain approval status and controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:56:28 +08:00
bincxz
a1d05ca5b3 fix: resolve tool call duplication and ordering in chat UI
Tool calls were rendered both in the assistant message (as pending)
and in separate tool-result messages (as completed), causing
duplicates. Additionally, new pending tool calls appeared above
completed ones due to message ordering.

Fix: render completed tool calls only from tool-result messages,
and render pending tool calls after all results so they appear
at the bottom in chronological order. Unresolved tool calls from
earlier assistant messages or cancelled sessions are shown inline
as interrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:54:17 +08:00
陈大猫
327ca3806a Merge pull request #577 from tces1/dev
feat: add GitHub Copilot CLI agent support
2026-03-30 18:24:39 +08:00
bincxz
2f71dd3927 revert: don't override copilot acpCommand with resolved path
On Windows the resolved path may be a .cmd shim which spawn()
cannot execute without shell: true. Keep acpCommand as the bare
"copilot" from AGENT_DEFAULTS and let the system resolve it via
PATH at launch time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:16:50 +08:00
bincxz
3844edd49f fix: clean up copilot temp dir even when provider init fails
Move COPILOT_HOME temp dir cleanup before the acpProviders entry
check so it runs even if provider creation failed before the entry
was stored in the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:57:00 +08:00
bincxz
8f97a7e81d fix: use resolved path as copilot acpCommand and add Windows home fallback
- When building managed copilot agent config, set acpCommand to the
  resolved path instead of bare "copilot" so custom paths work for
  ACP launches
- Add USERPROFILE fallback in prepareCopilotHome for Windows where
  HOME may not be set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:48:07 +08:00
bincxz
5daf1f0d6f fix: hoist copilotConfigInfo above try block to fix ReferenceError
copilotConfigInfo was declared with let inside the try block but
referenced in the finally block for temp dir cleanup. Block scoping
caused a ReferenceError that broke list-models for Copilot agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:38:39 +08:00
bincxz
b1a5b92ce4 fix: clean up transient copilot temp dirs and remove verbose MCP logs
- Add COPILOT_HOME cleanup in list-models finally block to prevent
  temp directory accumulation on each model fetch
- Remove verbose console.log in mcpServerBridge dispatch/connect/auth
  that fired on every MCP call for all agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:27:18 +08:00
bincxz
c99a70831a fix: address review issues in copilot agent integration
- Fix matchesManagedAgentConfig acpCommand matching for copilot by
  using a lookup table instead of hardcoded ternary
- Remove dead nodeRuntimePath variable and unused 4th arg to
  buildMcpServerConfig
- Fix model loading useEffect double-triggering by reading
  agentModelMap via ref instead of dependency
- Add temp COPILOT_HOME cleanup in cleanupAcpProvider
- Remove dead acpForceProviderReset Set (never populated after
  stop/resume refactor)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:22:59 +08:00
bincxz
4b0468b0d2 merge: resolve conflicts with main for copilot agent support
Adapt copilot agent additions to the refactored managed agent
architecture (resolveAgentPath + buildManagedAgentState pattern).
Add copilot to ManagedAgentKey type and MANAGED_AGENT_META.
Keep main's resolveMcpServerRuntimeCommand (process.execPath +
ELECTRON_RUN_AS_NODE) over PR's runtimeCommand parameter approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:14:45 +08:00
陈大猫
f32078f270 Merge pull request #575 from binaricat/codex/fix-codex-agent-path-and-mcp-startup
[codex] fix codex agent path detection and MCP startup
2026-03-30 17:02:06 +08:00
Eric Chan
a525c073b9 fix: matchesAgentCommand update for windows shim 2026-03-30 16:29:14 +08:00
bincxz
afceb92a55 fix: fall back to PATH search when stored CLI path is stale
When a previously stored custom path no longer exists (e.g. CLI
reinstalled to a different location), aiResolveCli now falls back
to PATH-based detection instead of returning unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:27:32 +08:00
bincxz
4822894efb refactor: eliminate circular effect dependency in managed agent consolidation
Move agent dedup/consolidation from a useEffect (that depended on
externalAgents while also setting it) into resolveAgentPath, using
setExternalAgents(prev => ...) callback form. Use a ref for
defaultAgentId to avoid dependency cycles and keep it in sync
across concurrent codex+claude resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:08:04 +08:00
Eric Chan
d9b51c3a50 feat: add GitHub Copilot CLI agent support 2026-03-30 15:53:08 +08:00
bincxz
15b1dba558 fix stale managed codex path reuse 2026-03-30 15:51:14 +08:00
bincxz
fd6b3930c1 fix codex managed-agent regressions 2026-03-30 15:26:44 +08:00
bincxz
53cb160a6e fix codex agent path detection and MCP startup 2026-03-30 15:04:06 +08:00
陈大猫
bb590f140d Merge pull request #574 from binaricat/fix/autocomplete-click-outside-dismiss
fix: dismiss autocomplete popup on click outside
2026-03-30 11:25:54 +08:00
bincxz
945992b80e fix: dismiss autocomplete popup on click outside
Closes #572

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

View File

@@ -43,6 +43,21 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
fi
- name: Set version
shell: bash
run: |

View File

@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
## How Things Talk
- UI calls application hooks hooks call domain helpers persistence/config via infrastructure adapters.
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.
## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
---
## Aside Panel Design System
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
Import from `./ui/aside-panel`:
```tsx
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
AsidePanelFooter,
AsideActionMenu,
AsideActionMenuItem
AsideActionMenuItem
} from "./ui/aside-panel";
```
### Basic Usage
```tsx
<AsidePanel
open={isOpen}
<AsidePanel
open={isOpen}
onClose={handleClose}
title="Panel Title"
subtitle="Optional subtitle"

330
App.tsx
View File

@@ -14,6 +14,7 @@ import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
@@ -23,18 +24,21 @@ import { applySyncPayload } from './application/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
@@ -175,16 +179,19 @@ function App({ settings }: { settings: SettingsState }) {
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
setTheme,
resolvedTheme,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
@@ -200,10 +207,11 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
workspaceFocusStyle,
} = settings;
const discoveredShells = useDiscoveredShells();
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
@@ -239,8 +247,11 @@ function App({ settings }: { settings: SettingsState }) {
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
importDataFromString,
groupConfigs,
updateGroupConfigs,
} = useVaultState();
const {
@@ -296,6 +307,12 @@ function App({ settings }: { settings: SettingsState }) {
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
@@ -305,6 +322,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const sessionByIdRef = useRef(sessionById);
sessionByIdRef.current = sessionById;
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
@@ -317,6 +336,11 @@ function App({ settings }: { settings: SettingsState }) {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
@@ -349,10 +373,9 @@ function App({ settings }: { settings: SettingsState }) {
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
isImmersive: immersiveMode,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
@@ -373,7 +396,7 @@ function App({ settings }: { settings: SettingsState }) {
);
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow } = useAutoSync({
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
hosts,
keys,
identities,
@@ -382,6 +405,7 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
@@ -426,7 +450,8 @@ function App({ settings }: { settings: SettingsState }) {
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
@@ -441,10 +466,12 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const effectiveHost = resolveEffectiveHost(host);
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -460,9 +487,9 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -479,6 +506,25 @@ function App({ settings }: { settings: SettingsState }) {
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
if (isCloseTabHotkey && dialogHotkeyScope) {
return;
}
if (isCloseTabHotkey) {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
e.preventDefault();
e.stopPropagation();
topmostDialogClose.click();
return;
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
@@ -611,6 +657,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
groupConfigs,
});
// Sync tray menu data + handle tray actions
@@ -690,6 +737,7 @@ function App({ settings }: { settings: SettingsState }) {
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
@@ -704,14 +752,29 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
@@ -802,32 +865,52 @@ function App({ settings }: { settings: SettingsState }) {
addConnectionLogRef.current = addConnectionLog;
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName: matchedShell?.name,
shellIcon: matchedShell?.icon,
});
}, [createLocalTerminal, terminalSettings.localShell]);
}, [createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}, [splitSession, terminalSettings.localShell]);
}, [splitSession, terminalSettings.localShell, discoveredShells]);
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return copySession(sessionId, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}, [copySession, terminalSettings.localShell]);
}, [copySession, terminalSettings.localShell, discoveredShells]);
const closeTabKeyStr = useMemo(() => {
if (hotkeyScheme === 'disabled') return null;
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
if (!closeTabBinding) return null;
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
}, [hotkeyScheme, keyBindings]);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
// doesn't land on a hidden tab (which would get redirected back) and so
// number shortcuts don't shift.
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs]
: ['vault', ...orderedTabs];
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
}
@@ -835,8 +918,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'nextTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -848,8 +929,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'prevTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -896,7 +975,9 @@ function App({ settings }: { settings: SettingsState }) {
setActiveTabId('vault');
break;
case 'openSftp':
setActiveTabId('sftp');
if (settings.showSftpTab) {
setActiveTabId('sftp');
}
break;
case 'quickSwitch':
case 'commandPalette':
@@ -922,32 +1003,32 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'splitHorizontal': {
// Split current terminal horizontally (top/bottom)
const currentId = activeTabStore.getActiveTabId();
// Check if it's a standalone session or we're in a workspace
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
// Split current terminal vertically (left/right)
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
@@ -984,7 +1065,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1032,6 +1113,9 @@ function App({ settings }: { settings: SettingsState }) {
}, [hosts, updateHosts, t]);
// System info for connection logs
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const systemInfoRef = useRef<{ username: string; hostname: string }>({
username: 'user',
hostname: 'localhost',
@@ -1053,13 +1137,24 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminalWithCurrentShell();
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName,
shellIcon,
});
addConnectionLog({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostLabel: shellName || 'Local Terminal',
hostname: 'localhost',
username: username,
protocol: 'local',
@@ -1068,16 +1163,24 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
return applyGroupDefaults(host, groupDefaults);
}, [groupConfigs]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (host.protocol === 'serial') {
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1093,9 +1196,9 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1108,7 +1211,18 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
}, [addConnectionLog, connectToHost, identities, keys]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
updateSessionStatus(sessionId, status);
if (status === 'connected') {
const session = sessionByIdRef.current.get(sessionId);
if (session?.hostId) {
updateHostLastConnected(session.hostId);
}
}
}, [updateSessionStatus, updateHostLastConnected]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
@@ -1162,24 +1276,25 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [sessions, connectionLogs, updateConnectionLog]);
// Check if host has multiple protocols enabled
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = resolveEffectiveHost(host);
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (host.protocol === 'ssh' || !host.protocol) count++;
if (effective.protocol === 'ssh' || !effective.protocol) count++;
// Mosh adds another option
if (host.moshEnabled) count++;
if (effective.moshEnabled) count++;
// Telnet adds another option
if (host.telnetEnabled) count++;
if (effective.telnetEnabled) count++;
// If protocol is explicitly telnet (not ssh), count it
if (host.protocol === 'telnet' && !host.telnetEnabled) count++;
if (effective.protocol === 'telnet' && !effective.telnetEnabled) count++;
return count > 1;
}, []);
}, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => {
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(host);
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
@@ -1187,7 +1302,7 @@ function App({ settings }: { settings: SettingsState }) {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}, [hasMultipleProtocols, handleConnectToHost]);
}, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => {
@@ -1204,10 +1319,24 @@ function App({ settings }: { settings: SettingsState }) {
}, [protocolSelectHost, handleConnectToHost]);
const handleToggleTheme = useCallback(() => {
// Toggle based on the actual rendered theme so clicking always produces a visible change,
// even when the stored preference is 'system'.
if (theme === 'system') {
toast.info(
t('topTabs.toggleTheme.systemExitMessage'),
{
title: t('topTabs.toggleTheme.systemExitTitle'),
actionLabel: t('topTabs.toggleTheme.openSettings'),
onClick: () => {
void (async () => {
const opened = await openSettingsWindow();
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
},
}
);
return;
}
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
}, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
@@ -1278,9 +1407,10 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -1299,10 +1429,11 @@ function App({ settings }: { settings: SettingsState }) {
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
isImmersiveActive={activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
/>
<div className="flex-1 relative min-h-0">
@@ -1329,6 +1460,8 @@ function App({ settings }: { settings: SettingsState }) {
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
groupConfigs={groupConfigs}
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onUpdateIdentities={updateIdentities}
@@ -1346,6 +1479,8 @@ function App({ settings }: { settings: SettingsState }) {
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
/>
@@ -1355,6 +1490,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -1369,6 +1505,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1378,6 +1515,7 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts={knownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
@@ -1387,8 +1525,9 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={updateSessionStatus}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
@@ -1436,6 +1575,17 @@ function App({ settings }: { settings: SettingsState }) {
})}
</div>
{/* Global "quick add snippet" dialog, triggered by the
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
<QuickAddSnippetDialog
snippets={snippets}
packages={snippetPackages}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
{isQuickSwitcherOpen && (
<Suspense fallback={null}>
<LazyQuickSwitcher
@@ -1444,6 +1594,7 @@ function App({ settings }: { settings: SettingsState }) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
@@ -1451,8 +1602,8 @@ function App({ settings }: { settings: SettingsState }) {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={() => {
handleCreateLocalTerminal();
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
@@ -1565,6 +1716,59 @@ function App({ settings }: { settings: SettingsState }) {
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
proceed. hideCloseButton removes the X button, onOpenChange
is a no-op so ESC also does nothing, and onInteractOutside
prevents click-away. */}
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
{t('sync.autoSync.emptyVaultConflict.title')}
</DialogTitle>
<DialogDescription>
{t('sync.autoSync.emptyVaultConflict.description')}
</DialogDescription>
</DialogHeader>
{emptyVaultConflict && (
<div className="bg-muted/30 rounded-lg p-3 text-sm">
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
})}</div>
</div>
)}
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
onClick={() => resolveEmptyVaultConflict('restore')}
className="w-full justify-start gap-2"
>
<Download className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.restore')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
</span>
</Button>
<Button
variant="outline"
onClick={() => resolveEmptyVaultConflict('keep-empty')}
className="w-full justify-start gap-2"
>
<Trash2 className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -21,6 +21,7 @@ const en: Messages = {
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
'common.add': 'Add',
'common.rename': 'Rename',
'common.refresh': 'Refresh',
'common.continue': 'Continue',
@@ -196,6 +197,15 @@ const en: Messages = {
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.application.openExternal.failedTitle': 'Cannot open link',
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
// Update notifications
'update.available.title': 'Update Available',
@@ -231,14 +241,11 @@ const en: Messages = {
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.immersiveMode': 'Immersive Mode',
'settings.appearance.immersiveMode.desc':
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -250,6 +257,8 @@ const en: Messages = {
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
'settings.terminal.themeModal.lightThemes': 'Light Themes',
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -327,6 +336,14 @@ const en: Messages = {
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
@@ -334,6 +351,11 @@ const en: Messages = {
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.shell.default': 'System Default',
'settings.terminal.localShell.shell.custom': 'Custom...',
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
'settings.terminal.localShell.shell.pathValid': 'Path valid',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
@@ -352,7 +374,7 @@ const en: Messages = {
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Workspace Focus Indicator
@@ -428,6 +450,18 @@ const en: Messages = {
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.autoSync.restoredTitle': 'Vault restored',
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
'sync.autoSync.keptLocalTitle': 'Kept local vault',
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
@@ -461,8 +495,24 @@ const en: Messages = {
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
'vault.groups.pathLabel': 'Path',
'vault.groups.settings': 'Group Settings',
'vault.groups.details': 'Group Details',
'vault.groups.details.general': 'General',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Advanced',
'vault.groups.details.appearance': 'Appearance',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Parent Group',
'vault.groups.details.none': 'None',
'vault.groups.details.inherited': 'Inherited from group',
'vault.groups.details.addProtocol': 'Add Protocol',
'vault.groups.details.removeProtocol': 'Remove Protocol',
'vault.groups.details.fontFamily': 'Font Family',
'vault.groups.details.fontSize': 'Font Size',
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
@@ -486,6 +536,10 @@ const en: Messages = {
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
'vault.hosts.recentlyConnected': 'Recently Connected',
'vault.hosts.pinToTop': 'Pin to Top',
'vault.hosts.unpin': 'Unpin',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
@@ -495,6 +549,7 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
@@ -882,6 +937,8 @@ const en: Messages = {
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
'qs.default': 'Default',
// Select Host panel
'selectHost.title': 'Select Host',
@@ -947,6 +1004,14 @@ const en: Messages = {
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
@@ -979,6 +1044,8 @@ const en: Messages = {
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -1089,7 +1156,7 @@ const en: Messages = {
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
@@ -1186,8 +1253,14 @@ const en: Messages = {
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.fontWeight': 'Font Weight',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
'terminal.hiddenTheme.title': 'Current hidden theme',
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
'topTabs.toggleTheme.openSettings': 'Open Settings',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
@@ -1317,6 +1390,22 @@ const en: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.revisionHistory.viewButton': 'History',
'cloudSync.revisionHistory.title': 'Vault Version History',
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
'cloudSync.revisionHistory.empty': 'No revisions found.',
'cloudSync.revisionHistory.current': 'Current',
'cloudSync.revisionHistory.revision': 'Revision',
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
'cloudSync.revisionHistory.device': 'Device',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Keys',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.identities': 'Identities',
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
'cloudSync.changeKey.title': 'Change Master Key',
'cloudSync.changeKey.current': 'Current Master Key',
'cloudSync.changeKey.new': 'New Master Key',
@@ -1604,10 +1693,7 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
@@ -1658,12 +1744,16 @@ const en: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
@@ -1674,7 +1764,6 @@ const en: Messages = {
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1687,10 +1776,37 @@ const en: Messages = {
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1745,6 +1861,7 @@ const en: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
@@ -1767,7 +1884,7 @@ const en: Messages = {
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
@@ -1777,7 +1894,7 @@ const en: Messages = {
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',

View File

@@ -13,6 +13,7 @@ const zhCN: Messages = {
'common.connect': '连接',
'common.terminal': '终端',
'common.create': '创建',
'common.add': '添加',
'common.rename': '重命名',
'common.refresh': '刷新',
'common.continue': '继续',
@@ -180,6 +181,15 @@ const zhCN: Messages = {
'settings.application.github.subtitle': '源代码',
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
'settings.application.openExternal.failedTitle': '无法打开链接',
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
// Update notifications
'update.available.title': '发现新版本',
@@ -215,13 +225,11 @@ const zhCN: Messages = {
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.immersiveMode': '沉浸模式',
'settings.appearance.immersiveMode.desc':
'启用后UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
@@ -261,6 +269,18 @@ const zhCN: Messages = {
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.autoSync.restoredTitle': '已恢复',
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
'sync.autoSync.keptLocalTitle': '已保留本地数据',
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
@@ -294,8 +314,24 @@ const zhCN: Messages = {
'vault.groups.placeholder.example': '例如Production',
'vault.groups.parentLabel': '父级',
'vault.groups.pathLabel': '路径',
'vault.groups.settings': '分组设置',
'vault.groups.details': '分组详情',
'vault.groups.details.general': '常规',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高级',
'vault.groups.details.appearance': '外观',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分组',
'vault.groups.details.none': '无',
'vault.groups.details.inherited': '继承自分组',
'vault.groups.details.addProtocol': '添加协议',
'vault.groups.details.removeProtocol': '移除协议',
'vault.groups.details.fontFamily': '字体',
'vault.groups.details.fontSize': '字号',
'vault.groups.errors.required': '分组名称不能为空。',
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
@@ -319,6 +355,10 @@ const zhCN: Messages = {
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
'vault.hosts.recentlyConnected': '最近连接',
'vault.hosts.pinToTop': '置顶',
'vault.hosts.unpin': '取消置顶',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
@@ -328,6 +368,7 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -542,6 +583,8 @@ const zhCN: Messages = {
'qs.search.placeholder': '搜索主机或标签页',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
'qs.default': '默认',
// Select Host panel
'selectHost.title': '选择主机',
@@ -603,6 +646,14 @@ const zhCN: Messages = {
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博网络',
'hostDetails.distro.option.huawei': '华为',
'hostDetails.distro.option.hpe': '慧与 / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': '飞塔',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
@@ -635,6 +686,8 @@ const zhCN: Messages = {
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -716,7 +769,7 @@ const zhCN: Messages = {
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
@@ -814,8 +867,14 @@ const zhCN: Messages = {
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.fontWeight': '字体粗细',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
'terminal.hiddenTheme.title': '当前隐藏主题',
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
'topTabs.toggleTheme.openSettings': '打开设置',
// Custom Themes
'terminal.customTheme.section': '自定义主题',
@@ -944,6 +1003,22 @@ const zhCN: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.revisionHistory.viewButton': '历史版本',
'cloudSync.revisionHistory.title': '主机库版本历史',
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
'cloudSync.revisionHistory.empty': '未找到修订记录。',
'cloudSync.revisionHistory.current': '当前版本',
'cloudSync.revisionHistory.revision': '修订',
'cloudSync.revisionHistory.revisionPreview': '修订内容',
'cloudSync.revisionHistory.device': '设备',
'cloudSync.revisionHistory.hosts': '主机',
'cloudSync.revisionHistory.keys': '密钥',
'cloudSync.revisionHistory.snippets': '代码片段',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
'cloudSync.changeKey.title': '更改主密钥',
'cloudSync.changeKey.current': '当前主密钥',
'cloudSync.changeKey.new': '新的主密钥',
@@ -1213,6 +1288,8 @@ const zhCN: Messages = {
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1283,6 +1360,14 @@ const zhCN: Messages = {
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
'settings.terminal.localShell.shell': 'Shell 可执行文件',
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe。留空使用系统默认。',
@@ -1290,6 +1375,11 @@ const zhCN: Messages = {
'settings.terminal.localShell.shell.detected': '检测到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
'settings.terminal.localShell.shell.default': '系统默认',
'settings.terminal.localShell.shell.custom': '自定义...',
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
'settings.terminal.localShell.shell.commonPaths': '常用路径',
'settings.terminal.localShell.shell.pathValid': '路径有效',
'settings.terminal.localShell.startDir': '起始目录',
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
@@ -1308,7 +1398,7 @@ const zhCN: Messages = {
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Autocomplete
@@ -1611,10 +1701,7 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
@@ -1665,12 +1752,16 @@ const zhCN: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty否则 Codex 无法鉴权。',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
@@ -1681,7 +1772,6 @@ const zhCN: Messages = {
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1694,10 +1784,37 @@ const zhCN: Messages = {
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '通过 ACP over stdio`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty内置',
'ai.toolAccess.title': '工具接入',
'ai.toolAccess.mode': 'Netcatty 接入模式',
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills默认只注入轻量索引只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
'ai.userSkills.reload': '重新加载 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在扫描用户 skills...',
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1752,6 +1869,7 @@ const zhCN: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -1774,7 +1892,7 @@ const zhCN: Messages = {
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
@@ -1784,7 +1902,7 @@ const zhCN: Messages = {
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',

View File

@@ -75,7 +75,6 @@ class CustomThemeStore {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
this.notify();
}
});
} catch {
@@ -129,6 +128,13 @@ class CustomThemeStore {
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
@@ -172,5 +178,9 @@ export const useCustomThemeActions = () => {
customThemeStore.deleteTheme(id);
}, []);
return { addTheme, updateTheme, deleteTheme };
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};

View File

@@ -68,8 +68,14 @@ class FontStore {
// Add default fonts first
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace to avoid collisions
// Build a set of built-in font family names for dedup (case-insensitive)
const builtinFamilyNames = new Set(
TERMINAL_FONTS.map(f => f.name.toLowerCase())
);
// Add local fonts, skipping those already covered by built-in fonts
localFonts.forEach(font => {
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
fontMap.set(localId, { ...font, id: localId });
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from "react";
import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";

View File

@@ -5,6 +5,7 @@ import {
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
@@ -19,6 +20,7 @@ import {
import type {
AISession,
AIPermissionMode,
AIToolIntegrationMode,
ProviderConfig,
HostAIPermission,
ExternalAgentConfig,
@@ -29,8 +31,17 @@ import type {
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
interface AIBridge {
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
@@ -192,6 +203,10 @@ export function useAIState() {
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
});
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
return stored === 'skills' ? 'skills' : 'mcp';
});
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
);
@@ -252,7 +267,7 @@ export function useAIState() {
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
@@ -330,6 +345,13 @@ export function useAIState() {
});
}, []);
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
const bridge = getAIBridge();
bridge?.aiMcpSetToolIntegrationMode?.(mode);
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
@@ -396,6 +418,15 @@ export function useAIState() {
}
break;
}
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
{
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
setToolIntegrationModeRaw(mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) {
@@ -511,8 +542,17 @@ export function useAIState() {
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
const initialPermMode: AIPermissionMode =
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
? storedPermMode
: 'confirm';
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
const initialToolMode: AIToolIntegrationMode =
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
}, []);
// ── Session CRUD ──
@@ -819,6 +859,8 @@ export function useAIState() {
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,

View File

@@ -15,15 +15,15 @@ export type SshAgentStatus = {
export const useApplicationBackend = () => {
const openExternal = useCallback(async (url: string) => {
try {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
await bridge.openExternal(url);
return;
}
} catch {
// Ignore and fall back below
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
// Bridge resolves on success (either via system browser or in-app
// fallback window) and rejects only when both paths fail. Let the
// rejection propagate so callers can present a user-facing message.
await bridge.openExternal(url);
return;
}
// Fallback for non-Electron environments (tests, dev server, etc.).
window.open(url, "_blank", "noopener,noreferrer");
}, []);

View File

@@ -22,6 +22,32 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
/**
* Check whether a sync payload has any meaningful user data. Covers all
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
* port forwarding rule) are not mistakenly treated as "empty".
*/
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
// Check all synced entity arrays.
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return false;
// Also consider settings: if any key has a defined value, the user has
// customized something worth preserving.
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
return false;
}
return true;
}
interface AutoSyncConfig {
// Data to sync
hosts: SyncPayload['hosts'];
@@ -32,6 +58,7 @@ interface AutoSyncConfig {
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -56,10 +83,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
* cloud vault before the startup pull has run. See #679. */
const remoteCheckDoneRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
// When checkRemoteVersion detects that the local vault is empty but
// the cloud has data, it pauses and exposes this state so the root
// component can render a confirmation dialog.
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
@@ -95,6 +139,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
@@ -105,6 +150,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
// Build sync payload
@@ -170,6 +216,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.credentialsUnavailable'));
}
// Prevent pushing an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// We only block auto-sync — manual trigger from Settings can still
// push if the user explicitly wants to.
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
@@ -231,18 +287,53 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remotePayload = await sync.downloadFromProvider(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const localPayload = buildPayload();
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
if (userAction === 'restore') {
config.onApplyPayload(remotePayload);
skipNextSyncRef.current = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Don't apply remote data.
// The next auto-sync will eventually push the empty state if
// the user makes another edit.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
}
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
// Prevent the data-change effect from immediately re-uploading the
// merged payload the merge already incorporated both sides. The
// next deliberate edit by the user will trigger a normal sync.
skipNextSyncRef.current = true;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
} finally {
remoteCheckDoneRef.current = true;
}
}, [sync, config, buildPayload, t]);
@@ -252,7 +343,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
// Don't auto-sync until the startup remote check has completed.
// Without this gate, an empty local vault can push to the cloud
// before checkRemoteVersion even runs, overwriting a non-empty
// remote vault — the exact bug described in #679.
if (!remoteCheckDoneRef.current) {
return;
}
// Skip initial render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
@@ -310,19 +409,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
// Reset check flag when provider disconnects
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
}
}, [sync.hasAnyConnectedProvider]);
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
// Guard: resolve only once (prevents double-click from entering an
// inconsistent state). The ref is nulled immediately so subsequent
// calls are no-ops.
const resolve = emptyVaultResolveRef.current;
if (!resolve) return;
emptyVaultResolveRef.current = null;
resolve(action);
}, []);
return {
syncNow,
buildPayload,
isSyncing: sync.isSyncing,
isConnected: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
emptyVaultConflict,
resolveEmptyVaultConflict,
};
};

View File

@@ -90,7 +90,21 @@ export interface CloudSyncHook {
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
// Gist Revision History
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
downloadGistRevision: (sha: string) => Promise<{
payload: SyncPayload;
meta: import('../../domain/sync').SyncFileMeta;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null>;
// Settings
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
@@ -475,6 +489,10 @@ export const useCloudSync = (): CloudSyncHook => {
syncToProvider: syncToProviderWithUnlock,
downloadFromProvider: downloadFromProviderWithUnlock,
resolveConflict: resolveConflictWithUnlock,
// Gist Revision History (#679)
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
downloadGistRevision: manager.downloadGistRevision.bind(manager),
// Settings
setAutoSync,

View File

@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
@@ -151,12 +152,10 @@ function removeImmersiveStyle() {
// ---------------------------------------------------------------------------
export function useImmersiveMode({
isImmersive,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
isImmersive: boolean;
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
@@ -170,18 +169,19 @@ export function useImmersiveMode({
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) {
if (isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
}, [isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
if (isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
@@ -198,7 +198,7 @@ export function useImmersiveMode({
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {

View File

@@ -4,7 +4,8 @@
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -19,6 +20,7 @@ export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
groupConfigs: GroupConfig[];
}
/**
@@ -29,11 +31,13 @@ export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
groupConfigs,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -73,6 +77,16 @@ export const usePortForwardingAutoStart = ({
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
}, []);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -89,11 +103,12 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Rule or host not found" };
}
const host = hostsRef.current.find((h) => h.id === rule.hostId);
if (!host) {
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
return { success: false, error: "Host not found" };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
};
@@ -101,7 +116,7 @@ export const usePortForwardingAutoStart = ({
return () => {
setReconnectCallback(null);
};
}, []);
}, [resolveEffectiveHost]);
// Auto-start rules on app launch
useEffect(() => {
@@ -146,8 +161,9 @@ export const usePortForwardingAutoStart = ({
// Start each auto-start rule
for (const rule of autoStartRules) {
const host = hosts.find((h) => h.id === rule.hostId);
if (host) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
@@ -180,5 +196,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys]);
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
};

View File

@@ -40,18 +40,26 @@ export const useSessionState = () => {
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
}) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
id: sessionId,
hostId: localHostId,
hostLabel: 'Local Terminal',
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -451,6 +459,10 @@ export const useSessionState = () => {
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
// Add pane to existing workspace
@@ -483,6 +495,10 @@ export const useSessionState = () => {
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
const hint: SplitHint = {
@@ -659,6 +675,10 @@ export const useSessionState = () => {
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
setActiveTabId(newSession.id);

View File

@@ -4,6 +4,7 @@ import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -33,10 +34,13 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_IMMERSIVE_MODE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -69,6 +73,9 @@ const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
const DEFAULT_SHOW_RECENT_HOSTS = true;
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
const DEFAULT_SHOW_SFTP_TAB = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -125,7 +132,7 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
@@ -195,6 +202,17 @@ export const useSettingsState = () => {
});
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (stored !== null) return stored === 'true';
// First time seeing this key. For genuinely fresh installs (no existing
// terminal theme in storage) default ON so the terminal matches the app
// theme out of the box. For upgrades from an older version (existing
// terminal theme present) default OFF to avoid silently overriding the
// user's manual choice.
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
@@ -247,6 +265,18 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
});
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -340,20 +370,6 @@ export const useSettingsState = () => {
}
}, []);
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (stored === null || stored === '') {
// Persist default so collectSyncableSettings() can include it
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
return true;
}
return stored === 'true';
});
const setImmersiveMode = useCallback((enabled: boolean) => {
setImmersiveModeState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
@@ -464,14 +480,12 @@ export const useSettingsState = () => {
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
if (storedImmersive === 'true' || storedImmersive === 'false') {
const val = storedImmersive === 'true';
setImmersiveModeState(val);
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -479,7 +493,7 @@ export const useSettingsState = () => {
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -561,6 +575,10 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
}
@@ -625,9 +643,6 @@ export const useSettingsState = () => {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
@@ -667,20 +682,22 @@ export const useSettingsState = () => {
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
globalHotkeyEnabled, autoUpdateEnabled,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -757,6 +774,13 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
if (next !== s.followAppTerminalTheme) {
setFollowAppTerminalThemeState(next);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
@@ -835,6 +859,24 @@ export const useSettingsState = () => {
setSftpDefaultViewMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showRecentHosts) {
setShowRecentHostsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
setShowSftpTabState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -849,13 +891,6 @@ export const useSettingsState = () => {
setAutoUpdateEnabled(newValue);
}
}
// Sync immersive mode from other windows
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.immersiveMode) {
setImmersiveModeState(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
@@ -881,6 +916,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -925,6 +966,27 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
}, [notifySettingsChanged]);
const setShowRecentHosts = useCallback((enabled: boolean) => {
setShowRecentHostsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
}, [notifySettingsChanged]);
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
setShowOnlyUngroupedHostsInRootState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
@@ -1148,12 +1210,21 @@ export const useSettingsState = () => {
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const currentTerminalTheme = useMemo(() => {
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0],
[terminalThemeId, customThemes]
);
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1188,6 +1259,8 @@ export const useSettingsState = () => {
setUiLanguage,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
@@ -1219,6 +1292,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1247,8 +1326,6 @@ export const useSettingsState = () => {
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
@@ -1259,7 +1336,8 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
]),
};
};

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a boolean value to localStorage.
* Syncs across components in the same window via a custom event,
* and across windows via the native storage event.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists (defaults to false)
* @returns A tuple of [value, setValue] similar to useState
@@ -16,9 +18,38 @@ export const useStoredBoolean = (
return stored ?? fallback;
});
useEffect(() => {
localStorageAdapter.writeBoolean(storageKey, value);
}, [storageKey, value]);
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
return resolved;
});
}, [storageKey]);
return [value, setValue] as const;
useEffect(() => {
// Sync from other components in the same window
const handleCustom = (e: Event) => {
const { key, value: newValue } = (e as CustomEvent).detail;
if (key === storageKey) setValue(newValue);
};
// Sync from other windows
const handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
const stored = localStorageAdapter.readBoolean(storageKey);
setValue(stored ?? fallback);
}
};
window.addEventListener("stored-boolean-change", handleCustom);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("stored-boolean-change", handleCustom);
window.removeEventListener("storage", handleStorage);
};
}, [storageKey, fallback]);
return [value, setAndPersist] as const;
};

View File

@@ -128,6 +128,22 @@ export const useTerminalBackend = () => {
return bridge.getSessionPwd(sessionId);
}, []);
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionRemoteInfo) {
return { success: false, error: 'getSessionRemoteInfo unavailable' };
}
return bridge.getSessionRemoteInfo(sessionId);
}, []);
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionDistroInfo) {
return { success: false, error: 'getSessionDistroInfo unavailable' };
}
return bridge.getSessionDistroInfo(sessionId);
}, []);
const getServerStats = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
@@ -150,6 +166,8 @@ export const useTerminalBackend = () => {
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
GroupConfig,
Host,
Identity,
KeyCategory,
@@ -17,6 +18,7 @@ import {
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_IDENTITIES,
@@ -30,9 +32,11 @@ import {
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptGroupConfigs,
decryptHosts,
decryptIdentities,
decryptKeys,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
@@ -46,6 +50,7 @@ type ExportableVaultData = {
customGroups: string[];
snippetPackages?: string[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
};
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
@@ -107,6 +112,7 @@ export const useVaultState = () => {
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
@@ -114,6 +120,7 @@ export const useVaultState = () => {
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
@@ -122,6 +129,7 @@ export const useVaultState = () => {
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
@@ -176,6 +184,15 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
const ver = ++groupConfigsWriteVersion.current;
encryptGroupConfigs(data).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
@@ -185,6 +202,7 @@ export const useVaultState = () => {
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
updateGroupConfigs([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
@@ -195,6 +213,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
]);
const addShellHistoryEntry = useCallback(
@@ -430,6 +449,20 @@ export const useVaultState = () => {
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
};
init();
@@ -529,6 +562,19 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
setManagedSources(next);
return;
}
if (key === STORAGE_KEY_GROUP_CONFIGS) {
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
++groupConfigsWriteVersion.current;
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
});
return;
}
};
@@ -536,6 +582,20 @@ export const useVaultState = () => {
return () => window.removeEventListener("storage", handleStorage);
}, []);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
setHosts((prev) => {
@@ -560,8 +620,9 @@ export const useVaultState = () => {
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
const importData = useCallback(
@@ -573,6 +634,7 @@ export const useVaultState = () => {
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
},
[
updateHosts,
@@ -582,6 +644,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateSnippetPackages,
updateKnownHosts,
updateGroupConfigs,
],
);
@@ -604,6 +667,7 @@ export const useVaultState = () => {
shellHistory,
connectionLogs,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
updateIdentities,
@@ -612,6 +676,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,
@@ -620,6 +685,7 @@ export const useVaultState = () => {
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
exportData,
importDataFromString,

View File

@@ -8,6 +8,7 @@
*/
import type {
GroupConfig,
Host,
Identity,
KnownHost,
@@ -41,7 +42,9 @@ import {
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -57,6 +60,7 @@ export interface SyncableVaultData {
customGroups: string[];
snippetPackages?: string[];
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
@@ -168,9 +172,13 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
if (showRecent != null) settings.showRecentHosts = showRecent;
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -234,8 +242,17 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +278,7 @@ export function buildSyncPayload(
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
syncedAt: Date.now(),
@@ -294,6 +312,9 @@ export function applySyncPayload(
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
vaultImport.groupConfigs = payload.groupConfigs;
}
importers.importVaultData(JSON.stringify(vaultImport));

View File

@@ -22,6 +22,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
AISessionScope,
ChatMessage,
@@ -31,6 +32,7 @@ import type {
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
@@ -38,11 +40,34 @@ import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import {
getReadyUserSkillOptions,
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import {
useAIChatStreaming,
getNetcattyBridge,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
@@ -74,6 +99,7 @@ interface AIChatSidePanelProps {
// Agent info
defaultAgentId: string;
toolIntegrationMode: AIToolIntegrationMode;
externalAgents: ExternalAgentConfig[];
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
agentModelMap: Record<string, string>;
@@ -126,11 +152,11 @@ function generateId(): string {
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
return message.content ? [{ role: 'user', content: message.content }] : [];
}
if (message.role === 'assistant') {
@@ -140,12 +166,12 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
return [{ role: 'assistant', content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
role: 'assistant',
content: `Tool result:\n${tr.content}`,
}));
}
@@ -195,6 +221,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
activeProviderId,
activeModelId,
defaultAgentId,
toolIntegrationMode,
externalAgents,
setExternalAgents,
agentModelMap,
@@ -226,6 +253,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const [selectedUserSkillSlugsMap, setSelectedUserSkillSlugsMap] = useState<Record<string, string[]>>({});
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
@@ -290,6 +320,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
if (scopeType === 'terminal' && scopeTargetId) {
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
if (target) {
return {
...target,
source: 'scope-target',
};
}
}
if (connectedSessions.length === 1) {
return {
...connectedSessions[0],
source: 'only-connected-in-scope',
};
}
return undefined;
}, [terminalSessions, scopeType, scopeTargetId]);
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
@@ -369,6 +422,43 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [terminalSessions, scopeKey, activeSessionId]);
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
const applyUserSkillsStatus = (result: { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null | undefined) => {
const nextOptions = getReadyUserSkillOptions(result);
setUserSkillOptions(nextOptions);
setSelectedUserSkillSlugsMap((prev) => getNextSelectedUserSkillSlugsMap(prev, result));
};
const bridge = getNetcattyBridge();
if (!bridge?.aiUserSkillsGetStatus) {
applyUserSkillsStatus(null);
return;
}
void bridge.aiUserSkillsGetStatus()
.then((result) => {
if (cancelled) return;
applyUserSkillsStatus(result);
})
.catch(() => {
if (cancelled) return;
applyUserSkillsStatus(null);
});
return () => {
cancelled = true;
};
}, [activeSessionIdForScope, isVisible, toolIntegrationMode, scopeKey]);
// Sync provider configs to main process so it can decrypt API keys server-side.
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
useEffect(() => {
@@ -413,6 +503,18 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
);
const messages = activeSession?.messages ?? [];
const selectedUserSkillSlugs = useMemo(
() => selectedUserSkillSlugsMap[scopeKey] ?? [],
[selectedUserSkillSlugsMap, scopeKey],
);
const selectedUserSkills = useMemo(
() =>
selectedUserSkillSlugs.map((slug) => {
const option = userSkillOptions.find((skill) => skill.slug === slug);
return option ?? { id: slug, slug, name: slug, description: '' };
}),
[selectedUserSkillSlugs, userSkillOptions],
);
// ── Export hook ──
const { handleExport } = useConversationExport(activeSession);
@@ -431,10 +533,128 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
const isCodexManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
[currentAgentConfig],
);
const isClaudeManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
[currentAgentConfig],
);
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
// so the picker can show just that model instead of the hardcoded ChatGPT
// preset list. Probing codex-acp for its full catalog returns the stock
// OpenAI models regardless of the active provider, which is misleading.
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void bridge.aiCodexGetIntegration().then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
// Only flip "resolved" to true when the probe confirms this is a
// custom-config session; otherwise keep it false so we fall back to
// the static CODEX_MODEL_PRESETS.
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => { cancelled = true; };
}, [isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
undefined,
undefined,
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this
// agent so `agentModelPresets` falls back to the hardcoded presets via
// the `?? getAgentModelPresets(...)` branch. Without this, a previously
// successful probe would keep surfacing models the backend no longer
// advertises.
if (result.models.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(currentAgentId in prev)) return prev;
const { [currentAgentId]: _removed, ...rest } = prev;
return rest;
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
// codexCustomConfigResolved (declared above alongside codexConfigModel)
// stays false until the integration probe confirms this session is
// custom-config, so we don't flash an empty picker while loading.
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
if (hasCodexCustomConfig) {
// Config.toml with a pinned model → show just that model.
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
// Config.toml custom provider without a pinned model → codex-acp
// uses its provider default. Don't surface the OpenAI presets; they
// wouldn't work. Empty list disables the picker.
return [];
}
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
@@ -471,6 +691,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setActiveSessionId(session.id);
setShowHistory(false);
setInputValue('');
setSelectedUserSkillSlugsMap((prev) => {
if (!(scopeKey in prev)) return prev;
const next = { ...prev };
delete next[scopeKey];
return next;
});
}, [
scopeType,
scopeTargetId,
@@ -479,6 +705,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
setActiveSessionId,
setInputValue,
scopeKey,
]);
const handleOpenSettings = useCallback(() => {
@@ -521,6 +748,41 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
}, []);
const addSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
setSelectedUserSkillSlugsMap((prev) => {
const current = prev[scopeKey] ?? [];
if (current.includes(normalizedSlug)) return prev;
return { ...prev, [scopeKey]: [...current, normalizedSlug] };
});
}, [scopeKey]);
const removeSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
setSelectedUserSkillSlugsMap((prev) => {
const current = prev[scopeKey] ?? [];
const nextSkills = current.filter((entry) => entry !== normalizedSlug);
if (nextSkills.length === current.length) return prev;
if (nextSkills.length === 0) {
const next = { ...prev };
delete next[scopeKey];
return next;
}
return { ...prev, [scopeKey]: nextSkills };
});
}, [scopeKey]);
const clearSelectedUserSkills = useCallback(() => {
setSelectedUserSkillSlugsMap((prev) => {
if (!(scopeKey in prev)) return prev;
const next = { ...prev };
delete next[scopeKey];
return next;
});
}, [scopeKey]);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
@@ -560,6 +822,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const trimmed = inputValueRef.current.trim();
const sendScopeKey = scopeKey;
if (!trimmed || isStreaming) return;
const selectedSkillSlugs = selectedUserSkillSlugs;
const isExternalAgent = currentAgentId !== 'catty';
@@ -586,6 +849,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
setInputValue('');
clearFiles();
clearSelectedUserSkills();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -593,7 +857,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
@@ -613,8 +879,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
defaultTargetSession,
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
@@ -642,6 +911,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
@@ -650,8 +920,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
selectedUserSkillSlugs, clearSelectedUserSkills,
]);
const handleStop = useCallback(() => {
@@ -711,7 +983,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
if (!isVisible) return null;
return (
<div className="flex flex-col h-full bg-background">
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
@@ -814,6 +1086,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>

View File

@@ -22,6 +22,7 @@ import {
Github,
Key,
Loader2,
History,
RefreshCw,
Settings,
Server,
@@ -285,6 +286,7 @@ interface ProviderCardProps {
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
extraActions?: React.ReactNode;
}
const ProviderCard: React.FC<ProviderCardProps> = ({
@@ -303,6 +305,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
onCancelConnect,
onDisconnect,
onSync,
extraActions,
}) => {
const { t } = useI18n();
const formatLastSync = (timestamp?: number): string => {
@@ -395,6 +398,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
)}
{t('cloudSync.provider.sync')}
</Button>
{extraActions}
{onEdit && (
<Button
size="sm"
@@ -715,6 +719,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Conflict modal
const [showConflictModal, setShowConflictModal] = useState(false);
// Gist revision history (#679)
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [historyRevisions, setHistoryRevisions] = useState<Array<{ version: string; date: Date }>>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPreview, setHistoryPreview] = useState<{
sha: string;
payload: SyncPayload;
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
deviceName?: string;
version?: number;
} | null>(null);
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
// Change master key dialog
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
const [currentMasterKey, setCurrentMasterKey] = useState('');
@@ -1030,6 +1048,60 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
};
// -- Gist revision history handlers --
const handleOpenHistory = async () => {
setShowHistoryModal(true);
setHistoryLoading(true);
setHistoryError(null);
setHistoryPreview(null);
setHistoryRevisions([]);
try {
const revisions = await sync.getGistRevisionHistory();
setHistoryRevisions(revisions);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : t('common.unknownError'));
} finally {
setHistoryLoading(false);
}
};
const handlePreviewRevision = async (sha: string) => {
setHistoryPreviewLoading(true);
setHistoryError(null);
try {
const result = await sync.downloadGistRevision(sha);
if (result) {
setHistoryPreview({
sha,
payload: result.payload,
preview: result.preview,
deviceName: result.meta.deviceName,
version: result.meta.version,
});
} else {
setHistoryError(t('cloudSync.revisionHistory.revisionNotFound'));
}
} catch {
// Decrypt failures can manifest as various error types:
// "Decryption failed", OperationError, "unable to authenticate
// data", AES-GCM tag mismatch, etc. Show the friendly message
// for any error originating from the decrypt step; network
// errors would have been caught by the fetch layer already.
setHistoryError(t('cloudSync.revisionHistory.decryptFailed'));
} finally {
setHistoryPreviewLoading(false);
}
};
const handleRestoreRevision = () => {
if (!historyPreview) return;
onApplyPayload(historyPreview.payload);
toast.success(t('cloudSync.revisionHistory.restored'));
setShowHistoryModal(false);
setHistoryPreview(null);
};
return (
<div className="space-y-6">
{/* Header with status */}
@@ -1091,6 +1163,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
extraActions={
isProviderReadyForSync(sync.providers.github) ? (
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
<History size={14} />
{t('cloudSync.revisionHistory.viewButton')}
</Button>
) : undefined
}
/>
<ProviderCard
@@ -1291,6 +1371,113 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => setShowConflictModal(false)}
/>
{/* Gist Revision History Modal (#679) */}
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History size={18} />
{t('cloudSync.revisionHistory.title')}
</DialogTitle>
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
</DialogHeader>
{historyError && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
{historyError}
</div>
)}
{historyLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : historyPreview ? (
// Preview of a selected revision
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="rounded-lg border p-4 space-y-2">
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
{historyPreview.deviceName && (
<div className="text-xs text-muted-foreground">
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
{historyPreview.version != null && ` · v${historyPreview.version}`}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
<span className="font-medium">{historyPreview.preview.hostCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
<span className="font-medium">{historyPreview.preview.keyCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
<span className="font-medium">{historyPreview.preview.identityCount}</span>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
{t('common.back')}
</Button>
<Button onClick={handleRestoreRevision} className="gap-1">
<Download size={14} />
{t('cloudSync.revisionHistory.restoreButton')}
</Button>
</DialogFooter>
</div>
) : (
// Revision list
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
{historyRevisions.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
{t('cloudSync.revisionHistory.empty')}
</div>
) : (
<div className="space-y-1 px-1">
{historyRevisions.map((rev, index) => (
<button
key={rev.version}
onClick={() => handlePreviewRevision(rev.version)}
disabled={historyPreviewLoading}
className={cn(
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
"hover:bg-accent border border-transparent hover:border-border",
index === 0 && "bg-primary/5 border-primary/20",
)}
>
<div>
<div className="font-medium">
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
</div>
<div className="text-xs text-muted-foreground">
{rev.date.toLocaleString()}
</div>
</div>
<div className="text-xs text-muted-foreground font-mono">
{rev.version.slice(0, 7)}
</div>
</button>
))}
</div>
)}
</div>
)}
{historyPreviewLoading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
<Loader2 size={24} className="animate-spin" />
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
<DialogHeader>

View File

@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
{t('common.noResults', { defaultValue: 'No hosts found' })}
</div>
) : (
filteredHosts.map(host => {
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
{t('common.create', { defaultValue: 'Create' })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -22,6 +22,16 @@ export const DISTRO_LOGOS: Record<string, string> = {
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
// Network device vendors — auto-detected from the SSH server
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
cisco: "/distro/cisco.svg",
juniper: "/distro/juniper.svg",
huawei: "/distro/huawei.svg",
hpe: "/distro/hpe.svg",
mikrotik: "/distro/mikrotik.svg",
fortinet: "/distro/fortinet.svg",
paloalto: "/distro/paloalto.svg",
zyxel: "/distro/zyxel.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -42,6 +52,15 @@ export const DISTRO_COLORS: Record<string, string> = {
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
// Network device vendor brand colors
cisco: "bg-[#1BA0D7]",
juniper: "bg-[#0A6EB4]",
huawei: "bg-[#CF0A2C]",
hpe: "bg-[#01A982]",
mikrotik: "bg-[#293239]",
fortinet: "bg-[#EE3124]",
paloalto: "bg-[#FA582D]",
zyxel: "bg-[#00497A]",
default: "bg-slate-600",
};

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,12 @@ import {
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import {
getEffectiveHostDistro,
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
@@ -43,7 +48,7 @@ import {
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -51,6 +56,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -82,7 +88,10 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
];
interface HostDetailsPanelProps {
initialData?: Host | null;
@@ -99,6 +108,9 @@ interface HostDetailsPanelProps {
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
onCreateTag?: (tag: string) => void; // Callback to create a new tag
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -116,6 +128,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCancel,
onCreateGroup,
onCreateTag,
groupDefaults,
groupConfigs = [],
layout = "overlay",
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
@@ -126,13 +141,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
id: crypto.randomUUID(),
label: "",
hostname: "",
port: 22,
username: "root",
port: groupDefaults?.port ? undefined : 22,
username: groupDefaults?.username ? "" : "root",
protocol: "ssh",
tags: [],
os: "linux",
authMethod: "password",
charset: "UTF-8",
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
@@ -199,9 +214,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const effectiveGroupDefaults = useMemo(() => {
const currentGroupPath = form.group || defaultGroup;
if (currentGroupPath && groupConfigs.length > 0) {
return resolveGroupDefaults(currentGroupPath, groupConfigs);
}
return groupDefaults;
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
const effectiveThemeId = useMemo(
() => resolveHostTerminalThemeId(form, terminalThemeId),
[form, terminalThemeId],
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
[effectiveGroupDefaults, form, terminalThemeId],
);
const effectiveFontSize = useMemo(
() => resolveHostTerminalFontSize(form, terminalFontSize),
@@ -282,12 +305,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const removeHostFromChain = (index: number) => {
setForm((prev) => ({
...prev,
hostChain: {
hostIds: (prev.hostChain?.hostIds || []).filter((_, i) => i !== index),
},
}));
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
};
const clearHostChain = useCallback(() => {
@@ -313,12 +334,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const removeEnvVar = (index: number) => {
setForm((prev) => ({
...prev,
environmentVariables: (prev.environmentVariables || []).filter(
(_, i) => i !== index,
),
}));
setForm((prev) => {
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
});
};
const handleSubmit = () => {
@@ -363,7 +382,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port || 22,
port: form.port ?? (groupDefaults?.port ? undefined : 22),
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
@@ -504,6 +523,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onSave={handleCreateGroup}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -516,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -533,6 +554,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -561,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -572,12 +595,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true}
selectedThemeId={effectiveThemeId}
onSelect={(themeId) => {
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
setActiveSubPanel("none");
return;
}
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
setActiveSubPanel("none");
}}
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -616,6 +644,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -626,6 +655,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true}
onClose={onCancel}
width="w-[420px]"
layout={layout}
dataSection="host-details-panel"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
@@ -752,8 +783,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
<Input
type="number"
value={form.port}
onChange={(e) => update("port", Number(e.target.value))}
value={form.port ?? ""}
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
className="h-8 flex-1 min-w-0 text-center"
/>
<span className="text-xs text-muted-foreground">
@@ -805,7 +837,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
if (!hasIdentities) {
return (
<Input
placeholder={t("hostDetails.username.placeholder")}
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => update("username", e.target.value)}
className="h-10"
@@ -824,7 +856,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<PopoverTrigger asChild>
<div className="relative">
<Input
placeholder={t("hostDetails.username.placeholder")}
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => {
const next = e.target.value;
@@ -1263,18 +1295,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
@@ -1286,6 +1320,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
{form.os === "linux" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</Card>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
@@ -1294,113 +1433,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
{form.os === "linux" && (
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
<div className="flex items-start gap-2">
<Globe size={14} className="mt-0.5 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Theme Selection */}
<button
type="button"
@@ -1606,6 +1638,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
</div>
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
@@ -1755,7 +1798,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
onClick={(e) => {
e.stopPropagation();
setForm((prev) => ({ ...prev, environmentVariables: [] }));
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
}}
/>
</button>
@@ -1778,7 +1821,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
</div>
<Textarea
placeholder={t("hostDetails.startupCommand.placeholder")}
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="min-h-[80px] font-mono text-sm"
@@ -1842,7 +1885,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{/* Telnet Charset */}
<Input
placeholder={t("hostDetails.charset.placeholder")}
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
value={form.charset || "UTF-8"}
onChange={(e) => update("charset", e.target.value)}
className="h-10"

View File

@@ -1,4 +1,4 @@
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
@@ -32,9 +32,12 @@ interface HostTreeViewProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
interface TreeNodeProps {
@@ -56,9 +59,12 @@ interface TreeNodeProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
@@ -81,9 +87,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -137,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
@@ -144,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
@@ -176,6 +195,15 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{hostsCountInNode}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
</button>
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
@@ -226,9 +254,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -244,6 +275,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
@@ -264,6 +296,7 @@ interface HostTreeItemProps {
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
@@ -278,6 +311,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
@@ -348,6 +382,15 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
{tags.length > 2 && '...'}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={13} />
</button>
</div>
</div>
</ContextMenuTrigger>
@@ -364,7 +407,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
@@ -396,12 +439,15 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -522,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -552,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
)}
</div>
);
};
};

View File

@@ -4,7 +4,7 @@
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request]);
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);

View File

@@ -515,12 +515,12 @@ echo $3 >> "$FILE"`);
{/* Main Content */}
<div
className={cn(
"flex-1 overflow-y-auto transition-all duration-200",
"flex-1 flex flex-col min-h-0 transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}
@@ -684,8 +684,10 @@ echo $3 >> "$FILE"`);
</div>
</div>
{/* Keys Section */}
<div className="space-y-3 p-3">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("keychain.section.keys")}
@@ -817,6 +819,7 @@ echo $3 >> "$FILE"`);
</div>
</div>
)}
</div>
</div>
{/* Slide-out Panel */}

View File

@@ -36,19 +36,35 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const explicitThemeId = useMemo(() => {
if (!log.themeId) return undefined;
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|| customThemes.some((theme) => theme.id === log.themeId);
return exists ? log.themeId : undefined;
}, [customThemes, log.themeId]);
useEffect(() => {
if (log.themeId && !explicitThemeId) {
onUpdateLog(log.id, { themeId: undefined });
}
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (log.themeId) {
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|| customThemes.find(t => t.id === log.themeId)
if (previewTheme) {
return previewTheme;
}
if (explicitThemeId) {
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|| customThemes.find(t => t.id === explicitThemeId)
|| defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [log.themeId, defaultTerminalTheme, customThemes]);
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
const currentFontSize = log.fontSize ?? defaultFontSize;
@@ -69,6 +85,12 @@ const LogViewComponent: React.FC<LogViewProps> = ({
onUpdateLog(log.id, { themeId });
}, [log.id, onUpdateLog]);
useEffect(() => {
if (!themeModalOpen) {
setPreviewTheme(null);
}
}, [themeModalOpen]);
// Handle font size change
const handleFontSizeChange = useCallback((fontSize: number) => {
onUpdateLog(log.id, { fontSize });
@@ -295,10 +317,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={currentTheme.id}
currentThemeId={explicitThemeId}
displayThemeId={currentTheme.id}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
onFontSizeChange={handleFontSizeChange}
onPreviewThemeChange={setPreviewTheme}
/>
</div>
);

View File

@@ -14,12 +14,14 @@ import React, { useCallback, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
GroupConfig,
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -66,6 +68,7 @@ interface PortForwardingProps {
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -77,6 +80,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
identities = [],
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -113,8 +117,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
async (rule: PortForwardingRule) => {
const _host = hosts.find((h) => h.id === rule.hostId);
if (!_host) {
const _rawHost = hosts.find((h) => h.id === rule.hostId);
if (!_rawHost) {
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
toast.error(
t("pf.error.hostNotFound"),
@@ -123,6 +127,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
return;
}
const _host = _rawHost.group
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
: _rawHost;
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
@@ -161,7 +169,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, setRuleStatus, startTunnel, t],
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel

View File

@@ -0,0 +1,185 @@
/**
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
* App root and triggered by the `netcatty:snippets:add` window event.
*
* Intentionally minimal: label + command + package only. Advanced fields
* (target hosts, shortkey, tags) can be set later via the full Snippets
* manager. This keeps the user in their terminal context instead of
* navigating to the Vault view just to add a command.
*/
import { Package } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { Snippet } from '../domain/models';
import { Button } from './ui/button';
import { Combobox } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
packages: string[];
onCreateSnippet: (snippet: Snippet) => void;
onCreatePackage?: (packagePath: string) => void;
}
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
snippets,
packages,
onCreateSnippet,
onCreatePackage,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
// Listen for the global "add snippet" request dispatched by the
// terminal-side ScriptsSidePanel + button. We reset form state on
// every open so stale input from a previous cancel does not leak.
useEffect(() => {
const handler = () => {
setLabel('');
setCommand('');
setPackagePath('');
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
return () => window.removeEventListener('netcatty:snippets:add', handler);
}, []);
// Auto-focus the label input once the dialog renders, so the user can
// start typing immediately after clicking the + button.
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
return () => window.clearTimeout(id);
}, [open]);
// Derive combobox options from the union of existing packages (from
// props) and any package path referenced by an existing snippet, so
// the user can reuse anything they see in the main snippets view.
const packageOptions = useMemo(() => {
const set = new Set<string>();
for (const p of packages) {
if (p) set.add(p);
}
for (const s of snippets) {
if (s.package) set.add(s.package);
}
return Array.from(set).sort().map((value) => ({ value, label: value }));
}, [packages, snippets]);
const canSave = label.trim().length > 0 && command.trim().length > 0;
const handleSave = useCallback(() => {
if (!canSave) return;
const trimmedPackage = packagePath.trim();
// If the user typed a brand new package name, surface it to the parent
// so it can be added to the user's package list alongside the snippet.
if (trimmedPackage && !packages.includes(trimmedPackage)) {
onCreatePackage?.(trimmedPackage);
}
onCreateSnippet({
id: crypto.randomUUID(),
label: label.trim(),
command, // preserve whitespace in multi-line commands
tags: [],
package: trimmedPackage || '',
targets: [],
});
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
e.preventDefault();
handleSave();
}
},
[canSave, handleSave],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.empty.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
</Label>
<Input
id="quick-add-snippet-label"
ref={labelInputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<Textarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px] font-mono text-xs"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
<Package size={12} /> {t('snippets.field.package')}
</Label>
<Combobox
value={packagePath}
onValueChange={setPackagePath}
options={packageOptions}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate
onCreateNew={setPackagePath}
createText={t('snippets.field.createPackage')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default QuickAddSnippetDialog;

View File

@@ -10,9 +10,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
type QuickSwitcherItem = {
type: "host" | "tab" | "workspace" | "action";
type: "host" | "tab" | "workspace" | "action" | "shell";
id: string;
data?: Host | TerminalSession | Workspace;
};
@@ -66,9 +67,10 @@ interface QuickSwitcherProps {
onSelect: (host: Host) => void;
onSelectTab: (tabId: string) => void;
onClose: () => void;
onCreateLocalTerminal?: () => void;
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
showSftpTab: boolean;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -83,8 +85,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose,
onCreateLocalTerminal,
keyBindings,
showSftpTab,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const filteredShells = useMemo(() => {
const list = !query.trim()
? discoveredShells
: discoveredShells.filter(
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
);
// Default shell first
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
}, [discoveredShells, query]);
// Get hotkey display strings
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
@@ -148,20 +163,30 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
// Tabs (built-in + sessions + workspaces)
items.push({ type: "tab", id: "vault" });
items.push({ type: "tab", id: "sftp" });
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
orphanSessions.forEach((s) =>
items.push({ type: "tab", id: s.id, data: s }),
);
workspaces.forEach((w) =>
items.push({ type: "workspace", id: w.id, data: w }),
);
// Quick connect actions
items.push({ type: "action", id: "local-terminal" });
// Local shells (or fallback action if discovery not ready)
if (filteredShells.length > 0) {
filteredShells.forEach((shell) =>
items.push({ type: "shell", id: shell.id }),
);
} else {
items.push({ type: "action", id: "local-terminal" });
}
} else {
// Recent connections only
results.forEach((host) =>
items.push({ type: "host", id: host.id, data: host }),
);
// Also include matching shells in search results
filteredShells.forEach((shell) =>
items.push({ type: "shell", id: shell.id }),
);
}
// Build index map for O(1) lookup
@@ -171,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces]);
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
@@ -210,6 +235,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose();
}
break;
case "shell": {
const shell = discoveredShells.find(s => s.id === item.id);
if (shell && onCreateLocalTerminal) {
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
onClose();
}
break;
}
}
};
@@ -286,7 +319,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =
@@ -369,21 +402,60 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
{/* Local Shells section */}
{/* Local Shells or fallback Local Terminal */}
{filteredShells.length > 0 ? (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.localShells")}
</span>
</div>
{filteredShells.map((shell) => {
const idx = getItemIndex("shell", shell.id);
const isSelected = idx === selectedIndex;
return (
<div
key={shell.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
if (onCreateLocalTerminal) {
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
onClose();
}
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<img
src={getShellIconPath(shell.icon)}
alt={shell.name}
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
/>
<span className="text-sm font-medium">{shell.name}</span>
{shell.isDefault && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{t("qs.default")}
</span>
)}
</div>
);
})}
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
) : onCreateLocalTerminal && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.localShells")}
</span>
</div>
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
@@ -397,10 +469,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Serial removed (not supported) */}
</div>
</div>
)}
</div>
</ScrollArea>
</div>

View File

@@ -5,7 +5,7 @@
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
// the "add" panel pre-opened, so the user does not have to leave the
// terminal to jump back and click "New Snippet".
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
}, []);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<button
type="button"
onClick={handleAddSnippet}
title={t('snippets.action.newSnippet')}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</div>
{/* Breadcrumb */}

View File

@@ -1,11 +1,9 @@
import {
ArrowLeft,
Check,
ChevronRight,
LayoutGrid,
Plus,
Search,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
@@ -14,6 +12,7 @@ import { Host, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
@@ -44,6 +43,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
layout?: AsidePanelLayout;
}
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
@@ -63,6 +63,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
layout = "overlay",
}) => {
const { t } = useI18n();
const panelTitle = title ?? t("selectHost.title");
@@ -205,35 +206,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<TooltipProvider delayDuration={300}>
<div
<AsidePanel
open={true}
onClose={onBack}
title={panelTitle}
subtitle={subtitle}
showBackButton={true}
onBack={onBack}
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
layout === "overlay" && "z-40",
showNewHostPanel && "overflow-visible",
className,
)}
layout={layout}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onBack}
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<ArrowLeft size={18} />
</button>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{panelTitle}</h3>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onBack}
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<X size={18} />
</button>
</div>
{/* Toolbar */}
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
@@ -277,7 +263,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</div>
{/* Content */}
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 min-w-0">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
@@ -398,7 +384,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</ScrollArea>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60">
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
@@ -436,7 +422,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onCreateGroup={onCreateGroup}
/>
)}
</div>
</AsidePanel>
</TooltipProvider>
);
};

View File

@@ -17,6 +17,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from './ui/aside-panel';
interface SerialPort {
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
groups = [],
onSave,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
@@ -164,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
layout={layout}
dataSection="serial-host-details-panel"
>
<AsidePanelContent>
{/* Label */}

View File

@@ -96,6 +96,18 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
const handleOpenExternal = async (url: string) => {
try {
await openExternal(url);
} catch (err) {
console.warn("[SettingsApplicationTab] openExternal failed:", err);
toast.error(
t("settings.application.openExternal.failedBody"),
t("settings.application.openExternal.failedTitle"),
);
}
};
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -198,25 +210,25 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
icon={<Bug size={18} />}
title={t("settings.application.reportProblem")}
subtitle={t("settings.application.reportProblem.subtitle")}
onClick={() => void openExternal(issueUrl)}
onClick={() => void handleOpenExternal(issueUrl)}
/>
<ActionRow
icon={<MessageCircle size={18} />}
title={t("settings.application.community")}
subtitle={t("settings.application.community.subtitle")}
onClick={() => void openExternal(discussionsUrl)}
onClick={() => void handleOpenExternal(discussionsUrl)}
/>
<ActionRow
icon={<Github size={18} />}
title="GitHub"
subtitle={t("settings.application.github.subtitle")}
onClick={() => void openExternal(REPO_URL)}
onClick={() => void handleOpenExternal(REPO_URL)}
/>
<ActionRow
icon={<Newspaper size={18} />}
title={t("settings.application.whatsNew")}
subtitle={t("settings.application.whatsNew.subtitle")}
onClick={() => void openExternal(releasesUrl)}
onClick={() => void handleOpenExternal(releasesUrl)}
/>
</div>
</div>

View File

@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
</div>
);
}
return this.props.children;
return (this.props as { children: React.ReactNode }).children;
}
}
@@ -56,6 +56,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
@@ -86,6 +88,8 @@ const SettingsAITabContainer: React.FC = () => {
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
@@ -113,6 +117,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState();
@@ -132,8 +137,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
return (
@@ -154,10 +159,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -285,8 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
showRecentHosts={settings.showRecentHosts}
setShowRecentHosts={settings.setShowRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
/>
)}

View File

@@ -671,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
@@ -700,6 +702,8 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&

View File

@@ -25,6 +25,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -51,6 +52,7 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
groupConfigs?: import('../domain/models').GroupConfig[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
@@ -67,6 +69,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
groupConfigs = [],
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
@@ -104,7 +107,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() =>
hosts.map(h => {
if (!h.group) return h;
const defaults = resolveGroupDefaults(h.group, groupConfigs);
return applyGroupDefaults(h, defaults);
}),
[hosts, groupConfigs],
);
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
// Get backend helpers for file downloads and local filesystem writes.
const {
@@ -454,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
@@ -471,6 +486,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.groupConfigs === next.groupConfigs &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&

View File

@@ -8,7 +8,7 @@ import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Combobox, ComboboxOption } from './ui/combobox';
@@ -18,6 +18,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { SortDropdown, SortMode } from './ui/sort-dropdown';
import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -720,6 +721,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
layout="inline"
/>
);
}
@@ -730,6 +732,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
open={true}
onClose={handleClosePanel}
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
layout="inline"
actions={
<Button
variant="ghost"
@@ -883,7 +886,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</AsidePanelContent>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<AsidePanelFooter>
<Button
className="w-full"
onClick={handleSubmit}
@@ -891,7 +894,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
>
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
}
@@ -905,6 +908,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
showBackButton={true}
onBack={handleClosePanel}
layout="inline"
>
{/* History List */}
<div
@@ -951,8 +955,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
return (
<div className="h-full flex gap-3 relative">
<div className="flex-1 flex flex-col min-h-0">
<TooltipProvider delayDuration={300}>
<div className="h-full min-h-0 flex relative">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-2">
{/* Search box */}
@@ -1059,7 +1064,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1079,11 +1084,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
</div>
@@ -1114,7 +1119,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1126,15 +1131,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
{snippet.command}
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
@@ -1254,6 +1266,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{/* Right Panel */}
{renderRightPanel()}
</div>
</TooltipProvider>
);
};

View File

@@ -28,6 +28,7 @@ import {
import {
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -44,8 +45,11 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
@@ -120,6 +124,7 @@ interface TerminalProps {
fontFamilyId: string;
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -194,6 +199,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme = false,
terminalSettings,
sessionId,
startupCommand,
@@ -240,6 +246,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
// Token for an in-flight retry chain. handleRetry sets this to a fresh
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
@@ -254,6 +265,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -327,6 +339,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
// look different from dynamically-set ones. After text appears on screen (status
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
useEffect(() => {
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
fontWeightFixupDoneRef.current = true;
const timer = setTimeout(() => {
if (!termRef.current) return;
// Re-read the current weight at fire time to avoid stale closures
const w = termRef.current.options.fontWeight;
if (w === 'normal' || w === 400) return;
termRef.current.options.fontWeight = 'normal';
termRef.current.options.fontWeight = w;
}, 200);
return () => clearTimeout(timer);
}, [status]);
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
@@ -490,16 +519,58 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
// for hosts classified as network devices (either via explicit
// deviceType='network' or via SSH banner detection that populated
// host.distro with a network-vendor ID). See #674: polling the stats
// command on Cisco / Huawei / Juniper etc. generates one AAA session
// log entry per poll because each exec channel is counted as a new
// session on those devices.
//
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
// because that honors the manual distro override (`distroMode: 'manual'`
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
// pinned an "ubuntu" icon on what is actually a Cisco host would
// otherwise silently re-enable the polling loop and re-introduce the
// AAA log flood this patch is meant to eliminate. The display icon can
// still be overridden (see DistroAvatar) — gating uses the raw detected
// `host.distro` and the explicit `host.deviceType` only.
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isSupportedOs,
isConnected: status === 'connected',
isVisible,
});
const zmodem = useZmodemTransfer(sessionId);
const zmodemToastedRef = useRef(false);
useEffect(() => {
if (zmodem.active) {
zmodemToastedRef.current = false;
return;
}
if (zmodemToastedRef.current) return;
if (zmodem.error) {
zmodemToastedRef.current = true;
toast.error(zmodem.error, 'ZMODEM');
} else if (zmodem.filename) {
zmodemToastedRef.current = true;
toast.success(
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
'ZMODEM',
);
}
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -553,10 +624,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
const effectiveFontSize = useMemo(
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
[fontSize, hasFontSizeOverride, host.fontSize],
);
const effectiveFontWeight = useMemo(
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
);
const resolvedFontFamily = useMemo(() => {
const hostFontId = hasFontFamilyOverride && host.fontFamily
? host.fontFamily
@@ -566,6 +642,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const effectiveTheme = useMemo(() => {
// When "Follow Application Theme" is on and there's no active
// preview, skip per-host overrides — all terminals should use the
// UI-matched theme passed via terminalTheme prop.
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
@@ -576,7 +656,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
chainHosts;
@@ -610,6 +690,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const teardown = () => {
retryTokenRef.current = null;
cleanupSession();
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
@@ -900,6 +981,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
scrollbarSliderBackground: effectiveTheme.colors.foreground + '33',
scrollbarSliderHoverBackground: effectiveTheme.colors.foreground + '66',
scrollbarSliderActiveBackground: effectiveTheme.colors.foreground + '80',
};
}
}, [effectiveTheme]);
@@ -912,8 +996,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
termRef.current.options.scrollback = terminalSettings.scrollback;
termRef.current.options.fontWeight = terminalSettings.fontWeight as
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
| 300
@@ -928,10 +1012,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
@@ -966,7 +1050,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
lastFittedSizeRef.current = null;
}
}
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -1015,10 +1099,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
@@ -1049,7 +1133,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [effectiveFontSize, resizeSession, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1086,12 +1170,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
}, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isSearchOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
@@ -1295,6 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCancelConnect = () => {
retryTokenRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
@@ -1314,6 +1425,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCloseDisconnectedSession = () => {
retryTokenRef.current = null;
onCloseSession?.(sessionId);
};
@@ -1355,6 +1467,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
// kicks off another retry while the chained writes below are still
// queued, the token will be invalidated and our callbacks will abort
// before opening a ghost backend session with no owning UI.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
@@ -1363,17 +1484,51 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "serial") {
sessionStarters.startSerial(termRef.current);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(termRef.current);
} else if (host.moshEnabled) {
sessionStarters.startMosh(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
const startNewSession = () => {
if (!retryStillActive()) return;
if (host.protocol === "serial") {
sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(term);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else {
sessionStarters.startSSH(term);
}
};
// Chain the whole preparation through xterm.write callbacks so everything
// lands in strict order — see #695. xterm.write is async, so without
// chaining, a fast reconnect path (local/serial especially) can interleave
// the new session's first bytes with our reset sequence, corrupting the
// first screen.
//
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
// we must be on the normal buffer before preserving.
term.write('\x1b[?1049l', () => {
if (!retryStillActive()) return;
// 2. Push the previous session's viewport into scrollback so the user
// can still read it after reconnect.
preserveTerminalViewportInScrollback(term);
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
// full-screen apps may have left on — DECCKM (otherwise arrow keys
// emit SS3 and break readline history), keypad mode, SGR,
// insert/replace, origin, cursor visibility — without clearing the
// buffer. DECSTR does not cover xterm-specific extensions, so also
// explicitly disable mouse tracking (1000/1002/1003/1006) and
// bracketed paste (2004). Finally home the cursor.
term.write(
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
// 4. Only now — after every prep byte has been applied to the
// terminal — start the new session, so its first output can't
// interleave with the reset sequence.
startNewSession,
);
});
};
const shouldShowConnectionDialog = status !== "connected"
@@ -1549,7 +1704,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
@@ -1937,7 +2092,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div
ref={containerRef}
className="absolute inset-x-0 bottom-0"
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
@@ -1963,6 +2118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
@@ -2047,6 +2203,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
)}
{/* ZMODEM transfer progress indicator */}
{zmodem.active && (
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
<ZmodemProgressIndicator
transferType={zmodem.transferType}
filename={zmodem.filename}
transferred={zmodem.transferred}
total={zmodem.total}
fileIndex={zmodem.fileIndex}
fileCount={zmodem.fileCount}
finalizing={zmodem.finalizing}
onCancel={zmodem.cancel}
/>
</div>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

View File

@@ -14,12 +14,15 @@ import { KeyBinding, TerminalSettings } from '../domain/models';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostFontWeightOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostFontWeightOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
@@ -29,7 +32,9 @@ import { useStoredNumber } from '../application/state/useStoredNumber';
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
@@ -309,6 +314,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
@@ -338,6 +344,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
groupConfigs: GroupConfig[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
@@ -347,6 +354,7 @@ interface TerminalLayerProps {
knownHosts?: KnownHost[];
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
@@ -356,6 +364,7 @@ interface TerminalLayerProps {
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
onUpdateHostDistro: (hostId: string, distro: string) => void;
@@ -391,6 +400,7 @@ interface TerminalLayerProps {
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
keys,
identities,
snippets,
@@ -400,6 +410,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
knownHosts = [],
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
@@ -409,6 +420,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
@@ -770,8 +782,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sessionHostsMap = useMemo(() => {
const map = new Map<string, Host>();
for (const session of sessions) {
const existingHost = hostMap.get(session.hostId);
if (existingHost) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs)
: {};
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
@@ -804,11 +822,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol: session.protocol ?? 'local' as const,
moshEnabled: session.moshEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
});
}
}
return map;
}, [sessions, hostMap]);
}, [sessions, hostMap, groupConfigs]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
@@ -817,12 +839,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
map.set(
session.id,
host.hostChain.hostIds
.map((hostId) => hostMap.get(hostId))
.map((hostId) => {
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
: {};
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap]);
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
const validTerminalTabIds = useMemo(() => {
const ids = new Set<string>();
@@ -1354,6 +1383,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
// sidebar appearance changes should update global settings, not per-host overrides.
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !hostMap.has(focusedHost.id);
}, [focusedHost, isFocusedHostLocal, hostMap]);
const rawFocusedHost = useMemo(() => {
if (!focusedHost) return null;
return hostMap.get(focusedHost.id) ?? null;
}, [focusedHost, hostMap]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
? themePreview.themeId
@@ -1366,9 +1406,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
const visibleFocusedThemeId = followAppTerminalTheme ? terminalTheme.id : focusedThemeId;
const previewedOrVisibleThemeId = activeThemePreviewId ?? visibleFocusedThemeId;
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
? (activeThemePreviewId ?? focusedThemeId)
: null;
? previewedOrVisibleThemeId
: (isVisible ? visibleFocusedThemeId : null);
const appliedPreviewSessionRef = useRef<string | null>(null);
const customThemes = useCustomThemes();
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
@@ -1460,9 +1504,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
useEffect(() => {
if (!followAppTerminalTheme) return;
if (themeCommitTimerRef.current) {
clearTimeout(themeCommitTimerRef.current);
themeCommitTimerRef.current = null;
}
const appliedSessionId = appliedPreviewSessionRef.current;
if (appliedSessionId) {
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [followAppTerminalTheme, themePreview.targetSessionId, themePreview.themeId]);
useEffect(() => {
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
const shouldKeepPreview =
activeSidePanelTab === 'theme' &&
!!previewTargetSessionId &&
panelOpen &&
themePreview.targetSessionId === previewTargetSessionId &&
!!themePreview.targetSessionId &&
!!themePreview.themeId;
@@ -1473,8 +1535,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}
@@ -1484,14 +1544,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (
themePreview.targetSessionId === previewTargetSessionId &&
themePreview.themeId &&
themePreview.themeId === focusedThemeId
themePreview.themeId === visibleFocusedThemeId
) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [focusedThemeId, previewTargetSessionId, themePreview]);
}, [previewTargetSessionId, themePreview, visibleFocusedThemeId]);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (!focusedHost || themeId === focusedThemeId) return;
if (!focusedHost || themeId === previewedOrVisibleThemeId) return;
applyTerminalPreviewVars(previewTargetSessionId, themeId);
applyTopTabsPreviewVars(themeId);
setThemePreview({ targetSessionId: previewTargetSessionId, themeId });
@@ -1500,14 +1560,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
themeCommitTimerRef.current = setTimeout(() => {
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
return;
}
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, theme: themeId, themeOverride: true });
}
});
}, 160);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId, previewedOrVisibleThemeId, rawFocusedHost]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (themeCommitTimerRef.current) {
@@ -1515,41 +1577,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
clearTerminalPreviewVars(previewTargetSessionId);
setThemePreview({ targetSessionId: null, themeId: null });
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostThemeOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId, rawFocusedHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
});
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
// Prefer raw (un-merged) host to avoid flattening group defaults
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
@@ -1562,8 +1647,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);
@@ -1644,11 +1729,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, []);
const resolvedPreviewTheme = useMemo(() => {
const themeId = activeThemePreviewId ?? focusedThemeId;
const themeId = previewedOrVisibleThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
@@ -1764,7 +1849,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (!activeWorkspace || !isFocusMode) return null;
return (
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
<div
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
data-section="terminal-workspace-sidebar"
>
{/* Header with view toggle */}
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">
@@ -1835,6 +1923,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
data-section="terminal-workspace"
style={{
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
@@ -2016,20 +2105,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
{activeSidePanelTab === 'theme' && (
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={activeThemePreviewId ?? focusedThemeId}
followAppTerminalTheme={followAppTerminalTheme}
currentThemeId={previewedOrVisibleThemeId}
globalThemeId={terminalTheme.id}
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
currentFontSize={focusedFontSize}
currentFontWeight={focusedFontWeight}
canResetTheme={focusedThemeOverridden}
canResetFontFamily={focusedFontFamilyOverridden}
canResetFontSize={focusedFontSizeOverridden}
canResetFontWeight={focusedFontWeightOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
onFontWeightChange={handleFontWeightChangeForFocusedSession}
onFontWeightReset={handleFontWeightResetForFocusedSession}
previewColors={resolvedPreviewTheme.colors}
/>
</div>
@@ -2171,6 +2265,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}

View File

@@ -20,6 +20,7 @@ loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { useClipboardBackend } from '../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -34,6 +35,8 @@ interface TextEditorModalProps {
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
}
// Map our language IDs to Monaco language IDs
@@ -122,12 +125,38 @@ const hslToHex = (hslString: string): string => {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
@@ -138,6 +167,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave,
editorWordWrap,
onToggleWordWrap,
hotkeyScheme,
keyBindings,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
@@ -158,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setBgColor(getBackgroundColor());
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);
@@ -216,6 +262,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
setHasChanges(content !== initialContent);
}, [content, initialContent]);
const closeTabBinding = useMemo(
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
@@ -347,8 +398,33 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
void handlePasteRef.current();
});
editor.focus();
}, []);
useEffect(() => {
if (!open) return;
const frame = window.requestAnimationFrame(() => {
editorRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [open]);
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
const isMac = hotkeyScheme === 'mac';
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopPropagation();
handleClose();
}, [closeTabBinding, handleClose, hotkeyScheme]);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
@@ -370,7 +446,12 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
hideCloseButton
data-hotkey-close-tab="true"
onKeyDownCapture={handleDialogKeyDownCapture}
>
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">

161
components/ThemeList.tsx Normal file
View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { getEffectiveHostDistro } from '../domain/host';
import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
@@ -20,6 +21,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
interface TopTabsProps {
theme: 'dark' | 'light';
followAppTerminalTheme?: boolean;
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
@@ -42,6 +44,7 @@ interface TopTabsProps {
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
showSftpTab: boolean;
}
// Detect local OS for local terminal tab icons
@@ -54,7 +57,7 @@ const localOsId = (() => {
})();
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
@@ -68,8 +71,19 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
// Local protocol → shell-specific icon if available, else OS-specific icon
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
// Use shell icon from discovery when available
const iconId = shellIcon || host?.localShellIcon;
if (iconId) {
return (
<img
src={getShellIconPath(iconId)}
alt={iconId}
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
/>
);
}
const logo = DISTRO_LOGOS[localOsId];
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
if (logo) {
@@ -215,6 +229,7 @@ WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
followAppTerminalTheme = false,
hosts,
sessions,
orphanSessions,
@@ -237,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
showSftpTab,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -522,7 +538,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{activeTabId === session.id && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
{/* Drop indicator line - before */}
@@ -540,7 +556,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} shellIcon={session.localShellIcon} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
@@ -621,7 +637,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
{/* Drop indicator line - before */}
@@ -753,6 +769,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
data-section="top-tabs"
className="relative w-full bg-secondary app-drag"
style={{
...dragRegionNoSelect,
@@ -797,40 +814,42 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
>
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
{showSftpTab && (
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
)}
</div>
{/* Scrollable tabs container with fade masks */}
@@ -923,7 +942,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive}
disabled={isImmersiveActive && !followAppTerminalTheme}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
@@ -952,7 +971,10 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow &&
prev.isImmersiveActive === next.isImmersiveActive
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
prev.isImmersiveActive === next.isImmersiveActive &&
prev.showSftpTab === next.showSftpTab
);
};

View File

@@ -10,6 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
@@ -116,7 +117,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities } = useVaultState();
const { hosts, keys, identities, groupConfigs } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
@@ -326,14 +327,17 @@ const TrayPanelContent: React.FC = () => {
disabled={isConnecting}
title={label}
onClick={() => {
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
const host = rawHost.group
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
: rawHost;
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
initial="instant"
resize="smooth"
role="log"
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
{...props}
/>
);

View File

@@ -62,7 +62,7 @@ export const MessageResponse = memo(
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
className?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
@@ -22,6 +22,10 @@ import {
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
interface ChatInputProps {
value: string;
@@ -48,6 +52,14 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
onRemoveUserSkill?: (slug: string) => void;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
@@ -72,38 +84,74 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
selectedUserSkills = [],
userSkills = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashSkillPicker = activeMenu === 'slashSkill';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
setSlashQuery('');
setSlashRange(null);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
if (!match) return null;
const start = beforeCaret.length - match[0].length + match[1].length;
return {
start,
end: beforeCaret.length,
query: String(match[2] || '').toLowerCase(),
};
}, []);
const getInputPanelMenuPos = useCallback(() => {
const rect = inputShellRef.current?.getBoundingClientRect();
if (!rect) return null;
const horizontalMargin = 12;
const safeRight = window.innerWidth - horizontalMargin;
const width = Math.min(rect.width, safeRight - rect.left);
return {
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width,
};
}, []);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
// Detect if user just typed @
if (
hosts.length > 0 &&
@@ -111,16 +159,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
return;
}
}, [onChange, value, hosts.length, showAtMention]);
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (userSkills.length > 0 && slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashSkill');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashSkillPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
@@ -133,10 +193,45 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setInputPanelPos(pos);
if (menu === 'slashSkill') {
setSlashQuery('');
setSlashRange(null);
}
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
const filteredUserSkills = userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
});
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
if (/\s$/.test(before) && /^\s/.test(after)) {
return `${before}${after.slice(1)}`;
}
return `${before}${after}`;
}, [slashRange, value]);
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
onAddUserSkill?.(skill.slug);
if (slashRange) {
onChange(removeSlashQueryFromInput());
}
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
.map((item: DataTransferItem) => item.getAsFile())
.filter((f): f is File => !!f);
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
@@ -166,21 +261,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
// Permission mode chip removed — agents run in autonomous mode
// selectedModelId may be "model/thinking" for codex
const selectedBaseModelId = selectedModelId?.split('/')[0];
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
// split on the first '/'. Match against the full id first; only treat the
// trailing segment as a thinking level when we find a preset whose
// declared thinkingLevels make the combined form equal to selectedModelId.
const { selectedPreset, selectedThinking } = (() => {
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
const direct = modelPresets.find(m => m.id === selectedModelId);
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
const viaThinking = modelPresets.find(
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
);
if (viaThinking) {
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
return { selectedPreset: viaThinking, selectedThinking: thinking };
}
return { selectedPreset: undefined, selectedThinking: undefined };
})();
const selectedBaseModelId = selectedPreset?.id;
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
@@ -224,13 +338,43 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
))}
</div>
</div>
)}
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
@@ -243,31 +387,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{host.label && host.hostname !== host.label ? (
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
)}
{/* / skill popover */}
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{filteredUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
role="option"
onClick={() => insertUserSkillToken(skill)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
@@ -322,48 +513,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
<button
type="button"
role="menuitem"
aria-label="Mention host"
onClick={() => openInputPanelMenu('atMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{userSkills.length > 0 && (
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
aria-label="Insert user skill"
onClick={() => openInputPanelMenu('slashSkill')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
<Package size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
</>,
document.body,
@@ -375,7 +548,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
if (rect) {
// Clamp so the popover stays inside the viewport when
// the chip is near the right edge of a narrow AI side
// panel.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('model');
} else {
closeAllMenus();
@@ -395,8 +574,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
@@ -420,12 +599,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
@@ -555,6 +733,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};

View File

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

View File

@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
@@ -112,7 +113,25 @@ export interface PanelBridge extends NetcattyBridge {
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpListModels?: (
acpCommand: string,
acpArgs?: string[],
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
@@ -130,6 +149,10 @@ export interface TerminalSessionInfo {
connected: boolean;
}
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -143,6 +166,45 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
@@ -227,6 +289,7 @@ export interface SendToCattyContext {
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
selectedUserSkillSlugs?: string[];
}
/** Context values needed by sendToExternalAgent that change frequently. */
@@ -235,8 +298,11 @@ export interface SendToExternalContext {
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
}
// -------------------------------------------------------------------
@@ -528,6 +594,11 @@ export function useAIChatStreaming({
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
@@ -537,11 +608,6 @@ export function useAIChatStreaming({
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
const agentProviderId = openaiProvider?.id;
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
@@ -623,17 +689,23 @@ export function useAIChatStreaming({
onDone: () => {},
},
abortController.signal,
agentProviderId,
// Managed ACP agents (codex, claude) must resolve auth from their own
// CLI config/login state, so we deliberately pass no providerId here.
// See issue #705 for Codex; same reasoning for Claude.
undefined,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
context.toolIntegrationMode,
context.defaultTargetSession,
userSkillsContext,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
@@ -667,6 +739,11 @@ export function useAIChatStreaming({
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
@@ -694,6 +771,7 @@ export function useAIChatStreaming({
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
userSkillsContext,
});
// Guard: activeProvider must exist for Catty agent path

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getNextSelectedUserSkillSlugsMap,
getReadyUserSkillOptions,
pruneSelectedUserSkillSlugsMap,
} from "./userSkillsState.ts";
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
assert.deepEqual(getReadyUserSkillOptions(null), []);
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
assert.deepEqual(
getReadyUserSkillOptions({
ok: true,
skills: [
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
status: "ready",
},
{
id: "beta",
slug: "beta",
name: "Beta",
description: "Beta helper",
status: "warning",
},
],
}),
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
);
});
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
assert.deepEqual(
pruneSelectedUserSkillSlugsMap(
{
"terminal:1": ["alpha", "missing"],
"workspace:1": ["missing"],
},
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
),
{
"terminal:1": ["alpha"],
},
);
});
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
const selected = {
"terminal:1": ["alpha", "missing"],
"workspace:1": ["beta"],
};
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, null),
selected,
);
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
selected,
);
});

View File

@@ -0,0 +1,73 @@
export interface UserSkillStatusItemLike {
id: string;
slug: string;
name: string;
description: string;
status: "ready" | "warning";
}
export interface UserSkillsStatusLike {
ok: boolean;
skills?: UserSkillStatusItemLike[];
}
export interface UserSkillOption {
id: string;
slug: string;
name: string;
description: string;
}
export function getReadyUserSkillOptions(
status: UserSkillsStatusLike | null | undefined,
): UserSkillOption[] {
if (!status?.ok || !Array.isArray(status.skills)) return [];
return status.skills
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
}));
}
export function pruneSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
options: UserSkillOption[],
): Record<string, string[]> {
const validSlugs = new Set(options.map((option) => option.slug));
let changed = false;
const nextEntries: Array<[string, string[]]> = [];
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
if (filteredSlugs.length !== slugs.length) changed = true;
if (filteredSlugs.length > 0) {
nextEntries.push([scopeKey, filteredSlugs]);
} else if (slugs.length > 0) {
changed = true;
}
}
if (!changed) {
return selectedByScope;
}
return Object.fromEntries(nextEntries);
}
export function getNextSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
status: UserSkillsStatusLike | null | undefined,
): Record<string, string[]> {
if (!status?.ok || !Array.isArray(status.skills)) {
return selectedByScope;
}
return pruneSelectedUserSkillSlugsMap(
selectedByScope,
getReadyUserSkillOptions(status),
);
}

View File

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

View File

@@ -5,7 +5,7 @@
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
title={t('hostDetails.group.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
{t('common.save')}

View File

@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { EnvVar } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
title={t('hostDetails.envVars.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave}>
{t('common.save')}

View File

@@ -7,7 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
{t('common.save')}

View File

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

View File

@@ -7,19 +7,25 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
AIToolIntegrationMode,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
@@ -27,6 +33,7 @@ import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
@@ -38,6 +45,7 @@ import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
@@ -56,6 +64,8 @@ interface SettingsAITabProps {
setActiveModelId: (id: string) => void;
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
toolIntegrationMode: AIToolIntegrationMode;
setToolIntegrationMode: (mode: AIToolIntegrationMode) => void;
externalAgents: ExternalAgentConfig[];
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
defaultAgentId: string;
@@ -70,6 +80,54 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -85,6 +143,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setActiveModelId,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
@@ -113,58 +173,46 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
}
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
if (!bridge?.aiResolveCli) return null;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
setResolving(true);
try {
@@ -174,32 +222,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
}, [setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -244,18 +308,14 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasOpenAiProviderKey = providers.some(
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
);
const refreshCodexIntegration = useCallback(async () => {
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration();
const integration = await bridge.aiCodexGetIntegration(opts);
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
@@ -365,6 +425,54 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [refreshCodexIntegration]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsGetStatus) {
setUserSkillsStatus({
ok: false,
error: t('ai.userSkills.unavailable'),
});
return;
}
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
const handleOpenUserSkillsFolder = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsOpenFolder) return;
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, []);
return (
<TabsContent
value="ai"
@@ -457,16 +565,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
isResolvingPath={isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -483,13 +590,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
isResolvingPath={isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- GitHub Copilot CLI Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
</div>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
customPath={copilotCustomPath}
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
@@ -507,13 +630,137 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
className="w-64"
/>
</SettingRow>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Link size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<SettingRow
label={t('ai.toolAccess.mode')}
description={t('ai.toolAccess.description')}
>
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
</SettingRow>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void refreshUserSkillsStatus()}
disabled={isLoadingUserSkills}
>
<RefreshCcw size={14} className="mr-2" />
{t('ai.userSkills.reload')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleOpenUserSkillsFolder()}
disabled={isLoadingUserSkills}
>
<FolderOpen size={14} className="mr-2" />
{t('ai.userSkills.openFolder')}
</Button>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
? t('ai.userSkills.summary', {
ready: String(userSkillsStatus.readyCount ?? 0),
warnings: String(userSkillsStatus.warningCount ?? 0),
})
: userSkillsStatus?.error || t('ai.userSkills.unavailable')}
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
}
>
{skill.status === "ready"
? t('ai.userSkills.status.ready')
: t('ai.userSkills.status.warning')}
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}
</div>
</div>
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}

View File

@@ -25,8 +25,12 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
showRecentHosts: boolean;
setShowRecentHosts: (enabled: boolean) => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -47,8 +51,12 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
} = props;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
@@ -258,17 +266,29 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.immersiveMode")} />
<SectionHeader title={t("settings.vault.title")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.immersiveMode")}
description={t("settings.appearance.immersiveMode.desc")}
label={t('settings.vault.showRecentHosts')}
description={t('settings.vault.showRecentHostsDesc')}
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
checked={!!isImmersive}
onChange={() => onToggleImmersive?.()}
checked={showOnlyUngroupedHostsInRoot}
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
label={t('settings.vault.showSftpTab')}
description={t('settings.vault.showSftpTabDesc')}
>
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />

View File

@@ -7,14 +7,16 @@ import type {
TerminalEmulationType,
TerminalSettings,
} from "../../../domain/models";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
import { cn } from "../../../lib/utils";
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
@@ -23,6 +25,193 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
const AddCustomRuleDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
editRule?: KeywordHighlightRule | null;
onAdd: (rule: KeywordHighlightRule) => void;
}> = ({ open, onOpenChange, editRule, onAdd }) => {
const { t } = useI18n();
const [label, setLabel] = useState('');
const [pattern, setPattern] = useState('');
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
// Populate form when editing
useEffect(() => {
if (open && editRule) {
setLabel(editRule.label);
setPattern(editRule.patterns[0] || '');
setColor(editRule.color);
setPatternError(null);
} else if (!open) {
reset();
}
}, [open, editRule]);
const handleSubmit = () => {
if (!label.trim() || !pattern.trim()) return;
try { new RegExp(pattern, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
}
// When editing, replace only the first pattern and keep any additional ones
const patterns = editRule
? [pattern, ...editRule.patterns.slice(1)]
: [pattern];
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
value={label}
onChange={(e) => setLabel(e.target.value)}
className="flex-1"
/>
<label className="relative flex-shrink-0">
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
</label>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
<Input
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
value={pattern}
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className={cn("font-mono", patternError && "border-destructive")}
/>
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
</div>
{label.trim() && pattern.trim() && !patternError && (
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
<span className="text-sm font-medium" style={{ color }}>{label}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const KeywordHighlightRulesEditor: React.FC<{
rules: KeywordHighlightRule[];
onChange: (rules: KeywordHighlightRule[]) => void;
}> = ({ rules, onChange }) => {
const { t } = useI18n();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
return (
<div className="space-y-2.5">
{rules.map((rule) => {
const custom = !isBuiltIn(rule.id);
return (
<div key={rule.id} className="flex items-center gap-2 group">
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
{rule.label}
</span>
{custom && (
<>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
</>
)}
</div>
<label className="relative flex-shrink-0">
<input
type="color"
value={rule.color}
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
className="sr-only"
/>
<span
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
style={{ backgroundColor: rule.color }}
/>
</label>
</div>
);
})}
<div className="flex pt-2 mt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => setAddDialogOpen(true)}
>
<Plus size={14} className="mr-1.5" />
{t('settings.terminal.keywordHighlight.addCustom')}
</Button>
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => {
onChange(rules.map((rule) => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return def ? { ...rule, color: def.color } : rule;
}));
}}
>
<RotateCcw size={14} className="mr-1.5" />
{t("settings.terminal.keywordHighlight.resetColors")}
</Button>
</div>
<AddCustomRuleDialog
open={addDialogOpen}
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
editRule={editingRule}
onAdd={(rule) => {
if (editingRule) {
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
} else {
onChange([...rules, rule]);
}
setEditingRule(null);
}}
/>
</div>
);
};
// Theme preview button component
const ThemePreviewButton: React.FC<{
theme: (typeof TERMINAL_THEMES)[0];
@@ -74,6 +263,8 @@ const ThemePreviewButton: React.FC<{
export default function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
setFollowAppTerminalTheme: (value: boolean) => void;
terminalFontFamilyId: string;
setTerminalFontFamilyId: (id: string) => void;
terminalFontSize: number;
@@ -90,6 +281,8 @@ export default function SettingsTerminalTab(props: {
const {
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -106,6 +299,20 @@ export default function SettingsTerminalTab(props: {
const [defaultShell, setDefaultShell] = useState<string>("");
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const discoveredShells = useDiscoveredShells();
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
if (!terminalSettings.localShell) return false;
return !discoveredShells.some(s => s.id === terminalSettings.localShell);
});
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
const [customShellDraft, setCustomShellDraft] = useState("");
// Update showCustomShellInput once discovered shells load
useEffect(() => {
if (!terminalSettings.localShell) return;
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Subscribe to custom theme changes so editing in-place triggers re-render
@@ -210,7 +417,7 @@ export default function SettingsTerminalTab(props: {
}
}, []);
// Validate shell path when it changes
// Validate shell path when it changes (only for custom paths, not discovered shell ids)
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const shellPath = terminalSettings.localShell;
@@ -220,6 +427,12 @@ export default function SettingsTerminalTab(props: {
return;
}
// Skip validation for discovered shell ids — only validate custom paths
if (discoveredShells.some(s => s.id === shellPath)) {
setShellValidation(null);
return;
}
if (!bridge?.validatePath) {
setShellValidation(null);
return;
@@ -240,7 +453,7 @@ export default function SettingsTerminalTab(props: {
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, t]);
}, [terminalSettings.localShell, discoveredShells, t]);
// Validate directory path when it changes
useEffect(() => {
@@ -282,11 +495,24 @@ export default function SettingsTerminalTab(props: {
return (
<SettingsTabContent value="terminal">
<SectionHeader title={t("settings.terminal.section.theme")} />
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<div className="rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.theme.followApp")}
description={t("settings.terminal.theme.followApp.desc")}
>
<Toggle
checked={followAppTerminalTheme}
onChange={setFollowAppTerminalTheme}
/>
</SettingRow>
</div>
{!followAppTerminalTheme && (
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
)}
<ThemeSelectModal
open={themeModalOpen}
@@ -694,47 +920,10 @@ export default function SettingsTerminalTab(props: {
/>
</div>
{terminalSettings.keywordHighlightEnabled && (
<div className="space-y-2.5">
{terminalSettings.keywordHighlightRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between">
<span className="text-sm" style={{ color: rule.color }}>
{rule.label}
</span>
<label className="relative">
<input
type="color"
value={rule.color}
onChange={(e) => {
const newRules = terminalSettings.keywordHighlightRules.map((r) =>
r.id === rule.id ? { ...r, color: e.target.value } : r,
);
updateTerminalSetting("keywordHighlightRules", newRules);
}}
className="sr-only"
/>
<span
className="block w-10 h-6 rounded-md cursor-pointer border border-border/50 hover:border-border transition-colors"
style={{ backgroundColor: rule.color }}
/>
</label>
</div>
))}
<Button
variant="ghost"
size="sm"
className="w-full mt-3 text-muted-foreground hover:text-foreground"
onClick={() => {
const resetRules = terminalSettings.keywordHighlightRules.map((rule) => {
const defaultRule = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return defaultRule ? { ...rule, color: defaultRule.color } : rule;
});
updateTerminalSetting("keywordHighlightRules", resetRules);
}}
>
<RotateCcw size={14} className="mr-2" />
{t("settings.terminal.keywordHighlight.resetColors")}
</Button>
</div>
<KeywordHighlightRulesEditor
rules={terminalSettings.keywordHighlightRules}
onChange={(rules) => updateTerminalSetting("keywordHighlightRules", rules)}
/>
)}
</div>
@@ -745,24 +934,43 @@ export default function SettingsTerminalTab(props: {
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<Input
value={terminalSettings.localShell}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
className={cn(
"w-48",
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
<select
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
value={
showCustomShellInput
? "__custom__"
: terminalSettings.localShell || ""
}
onChange={(e) => {
const value = e.target.value;
if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true);
} else {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", value);
}
}}
>
<option value="">
{t("settings.terminal.localShell.shell.default")}
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</option>
{discoveredShells.map((shell) => (
<option key={shell.id} value={shell.id}>
{shell.name}
</option>
))}
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
</select>
{showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell}
</span>
)}
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
</span>
)}
</div>
@@ -862,9 +1070,9 @@ export default function SettingsTerminalTab(props: {
options={[
{ value: "auto", label: t("settings.terminal.rendering.auto") },
{ value: "webgl", label: "WebGL" },
{ value: "canvas", label: "Canvas" },
{ value: "dom", label: "DOM" },
]}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "dom")}
className="w-32"
/>
</SettingRow>
@@ -919,6 +1127,73 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{/* Custom Shell Modal */}
<Dialog open={customShellModalOpen} onOpenChange={setCustomShellModalOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{t("settings.terminal.localShell.shell.custom")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customPath")}</label>
<Input
value={customShellDraft}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => setCustomShellDraft(e.target.value)}
className="w-full"
autoFocus
/>
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
</span>
)}
{shellValidation?.valid && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
{t("settings.terminal.localShell.shell.pathValid")}
</span>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
<div className="flex flex-wrap gap-1.5">
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => (
<button
key={p}
type="button"
onClick={() => setCustomShellDraft(p)}
className="text-xs px-2 py-1 rounded-md border border-border bg-muted/50 hover:bg-muted transition-colors"
>
{p}
</button>
))}
</div>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => setCustomShellModalOpen(false)}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={() => {
updateTerminalSetting("localShell", customShellDraft);
setShowCustomShellInput(true);
setCustomShellModalOpen(false);
}}
disabled={!customShellDraft.trim()}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{t("common.save")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsTabContent>
);
}

View File

@@ -15,7 +15,6 @@ export const CodexConnectionCard: React.FC<{
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasOpenAiProviderKey: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
@@ -31,7 +30,6 @@ export const CodexConnectionCard: React.FC<{
integration,
loginSession,
isLoading,
hasOpenAiProviderKey,
error,
onRefresh,
onConnect,
@@ -42,6 +40,14 @@ export const CodexConnectionCard: React.FC<{
const { t } = useI18n();
const found = pathInfo?.available;
const customConfigIncomplete = Boolean(
integration?.state === "connected_custom_config"
&& integration.customConfig
&& integration.customConfig.envKey
&& !integration.customConfig.envKeyPresent
&& !integration.customConfig.hasHardcodedApiKey,
);
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
@@ -52,9 +58,13 @@ export const CodexConnectionCard: React.FC<{
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
: integration?.state === "connected_custom_config"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
@@ -62,9 +72,11 @@ export const CodexConnectionCard: React.FC<{
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
: customConfigIncomplete
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
@@ -139,6 +151,9 @@ export const CodexConnectionCard: React.FC<{
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout}>
<LogOut size={14} className="mr-1.5" />
@@ -157,10 +172,23 @@ export const CodexConnectionCard: React.FC<{
</Button>
</div>
{hasOpenAiProviderKey && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
</>
)}

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,27 @@ import type {
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "connected_custom_config"
| "not_logged_in"
| "unknown";
export interface CodexCustomProviderConfig {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
@@ -37,6 +50,28 @@ export interface AgentPathInfo {
available: boolean;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
@@ -57,12 +92,14 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
openExternal?: (url: string) => Promise<void>;
}
@@ -82,6 +119,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
acpCommand: "claude-agent-acp",
acpArgs: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -108,12 +152,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -124,6 +169,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

View File

@@ -2,6 +2,7 @@ import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
@@ -35,6 +36,8 @@ interface SftpOverlaysProps {
handleSaveTextFile: (content: string) => Promise<void>;
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
@@ -69,6 +72,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
handleSaveTextFile,
editorWordWrap,
setEditorWordWrap,
hotkeyScheme,
keyBindings,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
@@ -139,6 +144,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
/>
{/* File Opener Dialog */}

View File

@@ -46,6 +46,8 @@ interface UseSftpViewPaneCallbacksParams {
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
}
export const useSftpViewPaneCallbacks = ({

View File

@@ -75,21 +75,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
if (isAlternateScreen) {
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
if (rightClickBehavior === 'paste') {
onPaste?.();
} else if (rightClickBehavior === 'select-word') {
e.preventDefault();
e.stopPropagation();
onSelectWord?.();
}
},
@@ -102,12 +107,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenu>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
onContextMenu={handleRightClick}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
{!isAlternateScreen && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />

View File

@@ -15,7 +15,7 @@ import { createPortal } from 'react-dom';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
@@ -125,16 +125,21 @@ interface ThemeCustomizeModalProps {
open: boolean;
onClose: () => void;
currentThemeId?: string;
displayThemeId?: string;
currentFontFamilyId?: string;
currentFontSize?: number;
/** Called immediately when user selects a theme (for real-time preview) */
onThemeChange?: (themeId: string) => void;
/** Called when the theme should return to inherited/default state */
onThemeReset?: () => void;
/** Called immediately when user selects a font (for real-time preview) */
onFontFamilyChange?: (fontFamilyId: string) => void;
/** Called immediately when user changes font size (for real-time preview) */
onFontSizeChange?: (fontSize: number) => void;
/** Called when user clicks Save to persist settings */
onSave?: () => void;
/** Optional live preview callback for consumers that render outside this modal */
onPreviewThemeChange?: (theme: TerminalTheme | null) => void;
}
// Memoized preview component to avoid re-rendering on every state change
@@ -261,26 +266,39 @@ const TerminalPreview = memo(({
));
TerminalPreview.displayName = 'TerminalPreview';
const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({
...theme,
colors: { ...theme.colors },
isCustom: true,
});
const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme);
export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
open,
onClose,
currentThemeId = 'termius-dark',
currentThemeId,
displayThemeId,
currentFontFamilyId = 'menlo',
currentFontSize = DEFAULT_FONT_SIZE,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontSizeChange,
onSave,
onPreviewThemeChange,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id;
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId);
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
const [fontSize, setFontSize] = useState(currentFontSize);
const [draftCustomThemes, setDraftCustomThemes] = useState<TerminalTheme[]>(() => customThemes.map(cloneTheme));
// Custom theme editor state
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
@@ -293,30 +311,37 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
font: currentFontFamilyId,
fontSize: currentFontSize,
});
const originalCustomThemesRef = useRef<TerminalTheme[]>([]);
const wasOpenRef = useRef(false);
// Combine built-in + custom themes
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
() => [...TERMINAL_THEMES, ...draftCustomThemes],
[draftCustomThemes]
);
// Sync state when modal opens
useEffect(() => {
if (open) {
if (open && !wasOpenRef.current) {
// Store original values for potential cancel
originalValuesRef.current = {
theme: currentThemeId,
font: currentFontFamilyId,
fontSize: currentFontSize,
};
originalCustomThemesRef.current = customThemes.map((theme) => ({
...cloneTheme(theme),
}));
// Initialize selected values
setSelectedTheme(currentThemeId);
setSelectedTheme(resolvedThemeId);
setSelectedFont(currentFontFamilyId);
setFontSize(currentFontSize);
setDraftCustomThemes(customThemes.map(cloneTheme));
setEditingTheme(null);
setIsNewTheme(false);
}
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
wasOpenRef.current = open;
}, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]);
const currentFont = useMemo(
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
@@ -326,6 +351,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
[selectedTheme, allThemes, editingTheme]
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(selectedTheme)
? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null
: null),
[selectedTheme]
);
useEffect(() => {
onPreviewThemeChange?.(open ? currentTheme : null);
}, [currentTheme, onPreviewThemeChange, open]);
// Handle theme selection - apply immediately for real-time preview
const handleThemeSelect = useCallback((themeId: string) => {
@@ -379,7 +414,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]);
setSelectedTheme(parsed.id);
onThemeChange?.(parsed.id);
setActiveTab('theme');
@@ -394,16 +429,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
reader.readAsText(file);
// Reset file input so the same file can be re-imported
e.target.value = '';
}, [addTheme, onThemeChange, t]);
}, [onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemes.find(t => t.id === themeId);
const theme = draftCustomThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
setActiveTab('custom');
}
}, [customThemes]);
}, [draftCustomThemes]);
const handleEditorBack = useCallback(() => {
@@ -412,40 +447,63 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
}, []);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId));
if (selectedTheme === themeId) {
setSelectedTheme(TERMINAL_THEMES[0].id);
onThemeChange?.(TERMINAL_THEMES[0].id);
const originalThemeId = originalValuesRef.current.theme;
const fallbackThemeId = originalThemeId && originalThemeId !== themeId
? originalThemeId
: (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id);
setSelectedTheme(fallbackThemeId);
if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) {
onThemeReset?.();
} else {
onThemeChange?.(fallbackThemeId);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, selectedTheme, onThemeChange]);
}, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]);
// Save: just close (changes are already applied)
const handleSave = useCallback(() => {
// If editing a custom theme, save it first
if (editingTheme) {
if (isNewTheme) {
addTheme(editingTheme);
setSelectedTheme(editingTheme.id);
onThemeChange?.(editingTheme.id);
} else {
updateTheme(editingTheme.id, editingTheme);
const originalThemes = originalCustomThemesRef.current;
const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme]));
const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme]));
for (const [id, originalTheme] of originalMap) {
if (!draftMap.has(id)) {
deleteTheme(id);
continue;
}
const nextTheme = draftMap.get(id)!;
if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) {
updateTheme(id, nextTheme);
}
}
for (const [id, draftTheme] of draftMap) {
if (!originalMap.has(id)) {
addTheme(draftTheme);
}
}
onSave?.();
onClose();
}, [editingTheme, isNewTheme, addTheme, updateTheme, onSave, onClose, onThemeChange]);
}, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]);
// Cancel: revert to original values
const handleCancel = useCallback(() => {
const original = originalValuesRef.current;
// Revert all changes
onThemeChange?.(original.theme);
if (original.theme) {
onThemeChange?.(original.theme);
} else {
onThemeReset?.();
}
onFontFamilyChange?.(original.font);
onFontSizeChange?.(original.fontSize);
onClose();
}, [onThemeChange, onFontFamilyChange, onFontSizeChange, onClose]);
}, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]);
// Handle ESC key - same as cancel, but skip when child editor is open
useEffect(() => {
@@ -465,7 +523,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
if (!open) return null;
// Separate built-in and custom themes for display in the theme list
const builtinThemes = TERMINAL_THEMES;
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
const modalContent = (
<div
@@ -541,6 +599,17 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{activeTab === 'theme' && (
<div className="space-y-1">
{hiddenSelectedTheme && (
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 mb-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-xs font-medium text-foreground">{hiddenSelectedTheme.name}</div>
<div className="text-[10px] text-muted-foreground mt-1">
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{/* Built-in themes */}
{builtinThemes.map(theme => (
<ThemeItem
@@ -551,12 +620,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
/>
))}
{/* Custom themes section */}
{customThemes.length > 0 && (
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
@@ -617,12 +686,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
/>
{/* Custom themes list */}
{customThemes.length > 0 && (
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
@@ -717,12 +786,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
setDraftCustomThemes((prev) => {
if (isNewTheme) {
return [...prev, cloneTheme(theme)];
}
return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry);
});
if (isNewTheme) {
addTheme(theme);
setSelectedTheme(theme.id);
onThemeChange?.(theme.id);
} else {
updateTheme(theme.id, theme);
if (selectedTheme === theme.id) {
onThemeChange?.(theme.id);
}

View File

@@ -6,11 +6,11 @@
* Changes apply in real-time.
*/
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
@@ -126,20 +126,25 @@ const FontItem = memo(({
FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
followAppTerminalTheme?: boolean;
currentThemeId: string;
globalThemeId: string;
currentFontFamilyId: string;
globalFontFamilyId: string;
currentFontSize: number;
currentFontWeight: number;
canResetTheme?: boolean;
canResetFontFamily?: boolean;
canResetFontSize?: boolean;
canResetFontWeight?: boolean;
onThemeChange: (themeId: string) => void;
onThemeReset?: () => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontFamilyReset?: () => void;
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
onFontWeightChange: (fontWeight: number) => void;
onFontWeightReset?: () => void;
isVisible?: boolean;
previewColors?: {
background: string;
@@ -148,20 +153,25 @@ interface ThemeSidePanelProps {
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
followAppTerminalTheme = false,
currentThemeId,
globalThemeId,
currentFontFamilyId,
globalFontFamilyId,
currentFontSize,
currentFontWeight,
canResetTheme = false,
canResetFontFamily = false,
canResetFontSize = false,
canResetFontWeight = false,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontFamilyReset,
onFontSizeChange,
onFontSizeReset,
onFontWeightChange,
onFontWeightReset,
isVisible = true,
previewColors,
}) => {
@@ -170,7 +180,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [activeTab, setActiveTab] = useState<TabType>(followAppTerminalTheme ? 'font' : 'theme');
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -183,6 +193,12 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
[allThemes, globalThemeId],
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(currentThemeId)
? TERMINAL_THEMES.find((theme) => theme.id === currentThemeId) || null
: null),
[currentThemeId],
);
const globalFont = useMemo(
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
[availableFonts, globalFontFamilyId],
@@ -256,9 +272,27 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
setIsNewTheme(false);
}, [deleteTheme, currentThemeId, onThemeChange]);
const themeEditingLocked = followAppTerminalTheme;
useEffect(() => {
if (themeEditingLocked && activeTab !== 'font') {
setActiveTab('font');
}
}, [activeTab, themeEditingLocked]);
useEffect(() => {
if (!themeEditingLocked || !editingTheme) return;
setEditingTheme(null);
setIsNewTheme(false);
}, [editingTheme, themeEditingLocked]);
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
const footerLabel = themeEditingLocked
? `${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId}${currentFontSize}px • ${currentFontWeight}`
: `${allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId}${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId}${currentFontSize}px • ${currentFontWeight}`;
const panelVars = {
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
@@ -281,17 +315,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
{!themeEditingLocked && (
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
)}
<button
onClick={() => setActiveTab('font')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
@@ -303,24 +339,37 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
{!themeEditingLocked && (
<button
onClick={() => setActiveTab('custom')}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
)}
</div>
{/* List Content */}
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{activeTab === 'theme' && (
{!themeEditingLocked && activeTab === 'theme' && (
<div>
{hiddenSelectedTheme && (
<div className="mx-2 mb-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--terminal-panel-border)', backgroundColor: 'var(--terminal-panel-hover)' }}>
<div className="text-[10px] uppercase tracking-wider mb-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-xs font-medium">{hiddenSelectedTheme.name}</div>
<div className="text-[10px] mt-1" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
@@ -383,7 +432,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
{!themeEditingLocked && activeTab === 'custom' && !editingTheme && (
<div>
<button
onClick={handleNewTheme}
@@ -497,10 +546,69 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
</div>
)}
{themeEditingLocked && canResetTheme && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalTheme')}
</div>
<button
onClick={onThemeReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
</div>
</div>
)}
{/* Font Weight Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontWeight')}
</div>
{canResetFontWeight && (
<button
onClick={onFontWeightReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<select
value={currentFontWeight}
onChange={(e) => onFontWeightChange(Number(e.target.value))}
className="flex-1 h-7 rounded-md border text-xs px-2 cursor-pointer"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<option value={100}>100 Thin</option>
<option value={200}>200 ExtraLight</option>
<option value={300}>300 Light</option>
<option value={400}>400 Normal</option>
<option value={500}>500 Medium</option>
<option value={600}>600 SemiBold</option>
<option value={700}>700 Bold</option>
<option value={800}>800 ExtraBold</option>
<option value={900}>900 Black</option>
</select>
</div>
</div>
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
{footerLabel}
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -943,15 +943,15 @@ function resolveAutocompleteCwd(
if (os === "windows") return fallbackCwd;
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
const isRelativePathWord = normalizedWord.length > 0 &&
!normalizedWord.startsWith("/") &&
!normalizedWord.startsWith("~/") &&
!normalizedWord.startsWith("-");
if (!isRelativePathWord) {
// Absolute or home-relative paths don't depend on cwd
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
return fallbackCwd;
}
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
// extraction which reflects the current visible prompt — more up-to-date
// than fallbackCwd when OSC 7 is not supported.
const promptCwd = extractPosixCwdFromPrompt(promptText);
return chooseAutocompleteCwd(promptCwd, fallbackCwd);
}
@@ -963,15 +963,16 @@ function chooseAutocompleteCwd(
if (!promptCwd) return fallbackCwd;
if (!fallbackCwd) return promptCwd;
if (promptCwd.startsWith("/")) {
// Prompt cwd is extracted from the currently visible prompt, so it tracks
// directory changes even when OSC 7 is not supported. Prefer it over
// fallbackCwd (which may be stale from initial connection) whenever it
// looks like a usable path.
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
return promptCwd;
}
if (promptCwd === "~" || promptCwd.startsWith("~/")) {
return fallbackCwd;
}
return promptCwd;
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
return fallbackCwd;
}
function extractPosixCwdFromPrompt(promptText: string): string | undefined {

View File

@@ -0,0 +1,85 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type InternalTerminal = XTerm & {
_core?: {
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_eraseAttrData?: () => unknown;
};
};
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
return 0;
}
const baseY = buffer.baseY;
for (let row = term.rows - 1; row >= 0; row--) {
const line = buffer.getLine(baseY + row);
if (!line) {
continue;
}
if (line.translateToString(true).length > 0) {
return row + 1;
}
}
return 0;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
return;
}
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) {
return;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
};
export const clearTerminalViewport = (term: XTerm): void => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") return;
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY === 0 && buffer.baseY === 0) return;
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) return;
// Push lines above cursor into scrollback so they are preserved.
// After cursorY scrolls the prompt line shifts to active-screen row 0.
for (let i = 0; i < cursorY; i++) {
scroll.call(internal._core, eraseAttr, false);
}
// Clear everything below the prompt and reposition the cursor on it.
// CSI coordinates are 1-indexed.
const col = cursorX + 1;
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
term.scrollToBottom();
});
};
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 3;
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;

View File

@@ -89,11 +89,23 @@ export function useServerStats({
const hasFetchedRef = useRef(false);
const connectedAtRef = useRef(0);
const fetchGenerationRef = useRef(0);
// Auto-disable polling after a few consecutive failures. This covers
// hosts the banner classifier could not identify (e.g. Juniper JUNOS,
// Arista EOS, Cisco NX-OS — all of which advertise themselves as
// OpenSSH but do not support the POSIX stats shell command). Without
// this, the hook would keep retrying forever and generate an AAA
// session log every refresh interval.
const CONSECUTIVE_FAILURE_LIMIT = 3;
const consecutiveFailuresRef = useRef(0);
const givenUpRef = useRef(false);
const fetchStats = useCallback(async () => {
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
return;
}
if (givenUpRef.current) {
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) {
@@ -104,6 +116,21 @@ export function useServerStats({
setIsLoading(true);
setError(null);
const markFailure = (message: string) => {
consecutiveFailuresRef.current += 1;
setError(message);
if (consecutiveFailuresRef.current >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. The caller's useEffect sees the
// givenUp flag via the next render cycle and we also clear the
// interval locally so no further ticks fire.
givenUpRef.current = true;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
};
try {
const result = await bridge.getServerStats(sessionId);
@@ -112,6 +139,7 @@ export function useServerStats({
if (result.success && result.stats) {
hasFetchedRef.current = true;
consecutiveFailuresRef.current = 0;
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
@@ -134,11 +162,17 @@ export function useServerStats({
lastUpdated: Date.now(),
});
} else if (result.error) {
setError(result.error);
markFailure(result.error);
} else {
// Response was not marked as success but has no error — treat as
// a soft failure. This happens e.g. when the stats shell pipeline
// returns a parse failure on a host that isn't a typical Linux
// distro (JUNOS, NX-OS, EOS).
markFailure('No stats returned');
}
} catch (err) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
markFailure(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
@@ -147,6 +181,15 @@ export function useServerStats({
}
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
// When the session changes (e.g., same tab reconnects to a different host
// while staying connected), reset the failure counter. Without this, a
// JUNOS session that tripped the counter would permanently suppress
// polling even after the tab reconnects to a Linux host.
useEffect(() => {
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
}, [sessionId]);
// Initial fetch and periodic refresh
useEffect(() => {
isMountedRef.current = true;
@@ -158,9 +201,13 @@ export function useServerStats({
}
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats and fetch state when disabled or not connected
// Reset stats and fetch state when disabled or not connected.
// Also reset the give-up flag so that a reconnect (possibly to a
// different host at the same sessionId slot) gets a fresh chance.
hasFetchedRef.current = false;
connectedAtRef.current = 0;
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
setStats({
cpu: null,
@@ -222,15 +269,23 @@ export function useServerStats({
// (e.g., tab was hidden while connected and is now becoming visible).
const connectionAge = Date.now() - connectedAtRef.current;
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// If we already gave up on this session (exceeded the consecutive
// failure limit), don't even schedule new timers on effect reruns
// such as visibility/tab-focus/settings changes. The cleanup at
// disconnect/sessionId change clears the flag for a fresh attempt.
const initialTimer = givenUpRef.current
? null
: setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
intervalRef.current = setInterval(fetchStats, intervalMs);
if (!givenUpRef.current) {
intervalRef.current = setInterval(fetchStats, intervalMs);
}
return () => {
isMountedRef.current = false;
clearTimeout(initialTimer);
if (initialTimer) clearTimeout(initialTimer);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { clearTerminalViewport } from "../clearTerminalViewport";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -65,7 +66,7 @@ export const useTerminalContextActions = ({
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
term.clear();
clearTerminalViewport(term);
}, [termRef]);
const onSelectWord = useCallback(() => {

View File

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

View File

@@ -10,9 +10,23 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { detectVendorFromSshVersion } from "../../../domain/host";
/** Timeout of distro detection task */
const DISTRO_DETECT_TIMEOUT = 8000; // ms
/**
* Per-connection token for stale-timer detection. The renderer reuses the
* same sessionId across reconnects within a tab, so comparing sessionIds
* cannot distinguish "the current attempt" from "a previous attempt on
* the same slot". We assign each startSSH call a fresh token object and
* store it in this module-local map, keyed by sessionId. A timer that
* was scheduled under an older token will see a different value here and
* bail out. The map entry for a sessionId is overwritten on each new
* connect and stays around until the app exits — since there is only one
* entry per active session, the memory cost is negligible.
*/
const connectionTokensBySessionId = new Map<string, object>();
const isConnectionTokenCurrent = (sessionId: string, token: object): boolean =>
connectionTokensBySessionId.get(sessionId) === token;
type TerminalBackendApi = {
backendAvailable: () => boolean;
@@ -38,6 +52,17 @@ type TerminalBackendApi = {
stdout?: string;
stderr?: string;
}>;
getSessionRemoteInfo?: (sessionId: string) => Promise<{
success: boolean;
remoteSshVersion?: string;
error?: string;
}>;
getSessionDistroInfo?: (sessionId: string) => Promise<{
success: boolean;
stdout?: string;
stderr?: string;
error?: string;
}>;
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
onSessionExit: (
sessionId: string,
@@ -172,7 +197,7 @@ const attachSessionToTerminal = (
term: XTerm,
id: string,
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
onConnected?: () => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
@@ -209,6 +234,9 @@ const attachSessionToTerminal = (
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.updateStatus("disconnected");
if (evt.error) {
ctx.setError(evt.error);
}
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
@@ -220,32 +248,73 @@ const attachSessionToTerminal = (
}
}
// Clean up the connection token for this sessionId so stale timers
// that haven't fired yet will fail the isConnectionTokenCurrent check
// (previously they would see the old token still in the map and pass).
connectionTokensBySessionId.delete(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
sessionId: string,
connectionToken: object,
) => {
if (!ctx.terminalBackend.execAvailable()) return;
// Stale-session guard: the renderer reuses ctx.sessionId across
// reconnects in the same tab, so comparing sessionIds is not enough.
// We compare against a per-connection token instead; if a newer
// connect attempt has run, it will have replaced the token in the
// module-level map and this check will fail. Repeated after every
// await because the session can change during an async call.
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
if (!isStillCurrent()) return;
// Step 1: try to classify from the SSH server identification string
// captured at handshake time. This is free (no extra channel) and
// reliably identifies most network-device vendors (Cisco IOS, Huawei
// VRP, HPE Comware, MikroTik, Fortinet, etc.) so we can skip the
// POSIX-shell probe entirely for those hosts — which otherwise fails
// and, on devices like Cisco / Juniper with AAA logging, generates an
// extra session log entry per connect.
try {
const res = await ctx.terminalBackend.execCommand({
hostname: ctx.host.hostname,
username: auth.username || "root",
port: ctx.host.port || 22,
password: auth.password,
privateKey: auth.key?.privateKey,
passphrase: auth.passphrase ?? auth.key?.passphrase,
command: "cat /etc/os-release 2>/dev/null || uname -a",
timeout: DISTRO_DETECT_TIMEOUT,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
return;
}
}
} catch (err) {
logger.warn("SSH banner vendor detection failed", err);
}
if (!isStillCurrent()) return;
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
// /etc/os-release probe to pick a distro-specific icon. We deliberately
// use `getSessionDistroInfo` which runs the probe on the *existing*
// SSH connection's exec channel instead of spinning up a brand new
// SSH client the way `execCommand` would. That saves a full handshake
// round-trip on every connect, and on OpenSSH-fronted network devices
// that we couldn't identify from the banner (JUNOS, NX-OS, EOS) it
// avoids one extra AAA session log entry per connect.
try {
if (ctx.terminalBackend.getSessionDistroInfo && sessionId) {
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
logger.warn("OS probe failed", err);
}
@@ -259,12 +328,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
@@ -296,8 +359,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
@@ -569,7 +630,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });
usedKey = key;
} catch (err) {
if (isAuthError(err) && hasPassword) {
ctx.setProgressLogs((prev) => [
@@ -577,14 +637,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
"Key auth failed. Trying password...",
]);
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
} else {
throw err;
}
}
} else {
id = await startAttempt({ password: effectivePassword });
usedPassword = effectivePassword;
}
if (unsubscribeChainProgress) unsubscribeChainProgress();
@@ -611,17 +669,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
// Run OS detection only after successful connection
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
// Run OS detection only after successful connection. Mint a fresh
// token for this specific connection attempt and register it as
// the current one for this sessionId slot; any previous timer
// scheduled against an earlier token will see the replacement
// and bail out. The detection function re-checks the token after
// every async await so a reconnect mid-probe is also caught.
{
const connectionToken = {};
connectionTokensBySessionId.set(id, connectionToken);
setTimeout(() => {
if (!isConnectionTokenCurrent(id, connectionToken)) return;
void runDistroDetection(ctx, id, connectionToken);
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -650,12 +711,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startTelnet = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -689,12 +744,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startMosh = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -745,12 +794,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startLocal = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln(
@@ -761,15 +804,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
// Get local shell configuration from terminal settings
const localShell = ctx.terminalSettings?.localShell;
// Per-session shell (from QuickSwitcher discovery or split/copy) takes priority.
// The global terminalSettings.localShell may contain a shell ID (e.g., "wsl-ubuntu")
// which was already resolved to command+args and stored on the session object by App.tsx.
// Only pass shell/shellArgs when we have concrete per-session values;
// otherwise omit them so the backend uses its own default shell detection.
const sessionShell = ctx.host.localShell;
const sessionShellArgs = ctx.host.localShellArgs;
const localStartDir = ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
sessionId: ctx.sessionId,
cols: term.cols,
rows: term.rows,
shell: localShell,
shell: sessionShell || undefined,
shellArgs: sessionShellArgs || undefined,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",

View File

@@ -1,7 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
@@ -26,10 +26,17 @@ import {
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
isEraseViewportSequence,
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import type {
Host,
KeyBinding,
@@ -128,6 +135,21 @@ const detectPlatform = (): XTermPlatform => {
return "darwin";
};
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
const first = match?.[0]?.trim();
return first || fontFamily;
};
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
const platform = detectPlatform();
const deviceMemoryGb =
@@ -160,9 +182,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
// "no limit", so map it to a large value instead.
const rawScrollback = settings?.scrollback ?? 10000;
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = settings?.fontWeight ?? 400;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
@@ -179,7 +205,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
@@ -188,6 +214,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
...(windowsPty ? { windowsPty } : {}),
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
rescaleOverlappingGlyphs: true,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
@@ -230,6 +258,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
theme: {
...ctx.terminalTheme.colors,
selectionBackground: ctx.terminalTheme.colors.selection,
// Scrollbar theming (xterm 6.0) — derive from foreground color
scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
},
});
@@ -307,19 +339,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
webglLoaded = true;
} catch (webglErr) {
logger.warn(
"[XTerm] WebGL addon failed, using canvas renderer. Error:",
"[XTerm] WebGL addon failed, using DOM renderer. Error:",
webglErr instanceof Error ? webglErr.message : webglErr,
);
}
} else {
logger.info(
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
);
}
scopedWindow.__xtermWebGLLoaded = webglLoaded;
scopedWindow.__xtermRendererPreference = performanceConfig.preferCanvasRenderer
? "canvas"
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
? "dom"
: "webgl";
const webLinksAddon = new WebLinksAddon((event, uri) => {
@@ -354,9 +386,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
term.loadAddon(webLinksAddon);
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
const unicodeGraphemes = new UnicodeGraphemesAddon();
term.loadAddon(unicodeGraphemes);
term.unicode.activeVersion = '15-graphemes';
logRenderer();
@@ -392,12 +425,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (!consumed) return false; // Event was consumed by autocomplete
}
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
e.preventDefault();
ctx.setIsSearchOpen(true);
return false;
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
@@ -475,7 +502,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
break;
}
case "clearBuffer": {
term.clear();
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
@@ -562,7 +589,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
} else {
// Character mode (default): send immediately
ctx.terminalBackend.writeToSession(id, data);
// When backspaceBehavior is configured, remap the Backspace key output
let outData = data;
if (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
outData = "\x08";
}
ctx.terminalBackend.writeToSession(id, outData);
// Local echo for serial connections only when explicitly enabled
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
@@ -579,7 +611,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
// Use remapped data so broadcast peers also receive the correct byte
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
}
scrollToBottomAfterInput(data);
@@ -611,6 +645,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
if (isEraseViewportSequence(params)) {
preserveTerminalViewportInScrollback(term);
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
return true;
});
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
@@ -733,6 +778,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
@@ -44,6 +44,12 @@ interface AsidePanelProps {
children: ReactNode;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
*/
dataSection?: string;
}
interface AsidePanelHeaderProps {
@@ -101,8 +107,8 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
className,
}) => {
return (
<ScrollArea className={cn("flex-1 min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-x-hidden">
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
{children}
</div>
</ScrollArea>
@@ -171,14 +177,40 @@ interface AsidePanelStackProps {
initialItem: AsideContentItem;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS.
*/
dataSection?: string;
}
export type AsidePanelLayout = 'overlay' | 'inline';
const resolveInlineWidth = (width: string) => {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
return arbitraryWidthMatch[1];
}
switch (width) {
case 'w-full':
return '100%';
case 'w-screen':
return '100vw';
default:
return '380px';
}
};
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
open,
onClose,
initialItem,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
@@ -205,6 +237,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
const currentItem = stack[stack.length - 1];
const canGoBack = stack.length > 1;
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
// Reset stack when panel closes/opens
React.useEffect(() => {
@@ -218,10 +257,14 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
<AsidePanelHeader
title={currentItem.title}
subtitle={currentItem.subtitle}
@@ -248,15 +291,29 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
children,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
if (!open) return null;
const inlineWidth = resolveInlineWidth(width);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
{title && (
<AsidePanelHeader
title={title}

View File

@@ -48,8 +48,19 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close
data-dialog-close="true"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
>
{t("common.close")}
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<DialogPrimitive.Close
data-dialog-close="true"
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>

View File

@@ -88,5 +88,17 @@ export const findSyncPayloadEncryptedCredentialPaths = (
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(config.telnetPassword)) {
issues.push(`groupConfigs[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(config.proxyConfig?.password)) {
issues.push(`groupConfigs[${index}].proxyConfig.password`);
}
});
return issues;
};

80
domain/groupConfig.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { GroupConfig, Host } from './models';
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
*/
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
const merged: Record<string, unknown> = {};
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i + 1).join('/');
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
(key === 'fontSize' && config.fontSizeOverride === false) ||
(key === 'fontWeight' && config.fontWeightOverride === false)
) {
continue;
}
if (key !== 'path' && value !== undefined) {
merged[key] = value;
}
}
if (config.themeOverride === false) {
delete merged.themeOverride;
}
if (config.fontFamilyOverride === false) {
delete merged.fontFamilyOverride;
}
if (config.fontSizeOverride === false) {
delete merged.fontSizeOverride;
}
if (config.fontWeightOverride === false) {
delete merged.fontWeightOverride;
}
}
}
return merged as Partial<GroupConfig>;
}
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
/**
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
const effective = { ...host };
for (const key of INHERITABLE_KEYS) {
const hostValue = (host as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}
return effective;
}
export function resolveGroupTerminalThemeId(
groupDefaults: Partial<GroupConfig> | undefined,
fallbackThemeId: string,
): string {
if (!groupDefaults) return fallbackThemeId;
return groupDefaults.theme || fallbackThemeId;
}

View File

@@ -17,6 +17,26 @@ export const LINUX_DISTRO_OPTIONS = [
'kali',
] as const;
/**
* Known network-device vendor IDs that Netcatty can detect from the SSH
* server identification string. When a host is classified as one of these,
* features that assume a POSIX shell (e.g. the periodic server stats poll)
* are disabled, because the stats command would either be rejected outright
* or generate one AAA session log per poll on the remote device.
*/
export const NETWORK_DEVICE_OPTIONS = [
'cisco',
'juniper',
'huawei',
'hpe',
'mikrotik',
'fortinet',
'paloalto',
'zyxel',
] as const;
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
export const normalizeDistroId = (value?: string) => {
const v = (value || '').toLowerCase().trim();
if (!v) return '';
@@ -33,10 +53,87 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
// Network device vendor IDs may arrive here after detection — preserve them.
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return v;
if (v === 'linux' || v.includes('linux')) return 'linux';
return '';
};
/**
* Parse the SSH server identification string (the `software` portion of
* `SSH-<protocol>-<software>\r\n`, exposed by ssh2 as `conn._remoteVer`)
* and return a normalized network-device vendor ID, or '' if the ident
* does not match a known vendor.
*
* Matching patterns are sourced from the Nmap nmap-service-probes
* database (match lines beginning with `match ssh m|^SSH-`), cross-
* referenced with the ssh-audit project's software.py and vendor docs.
* Only rules whose pattern can be reproduced from those sources are
* included here.
*
* Empty string means "use the fallback linux/macos detection path" —
* that is what happens for OpenSSH, Dropbear, JUNOS, Cisco NX-OS, and
* Arista EOS, all of which either are POSIX systems or present as
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim();
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
if (/^Cisco[-_]/i.test(s)) return 'cisco';
if (/^CiscoIOS_/i.test(s)) return 'cisco';
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
// to a specific vendor icon from the banner alone. Users who want a
// custom icon for such devices can set one via the Host Details
// manual distro override. The stats-polling gate is still handled
// correctly via `host.deviceType === 'network'`.
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
// by the fallback failure-counter path in useServerStats).
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
// HPE / H3C — Comware switches, Integrated Lights-Out (iLO), legacy 3Com
if (/^Comware-/i.test(s)) return 'hpe';
if (/^3Com\s*OS/i.test(s)) return 'hpe';
if (/^mpSSH_/i.test(s)) return 'hpe';
// MikroTik RouterOS
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
// Fortinet FortiOS / FortiGate
if (/^FortiSSH_/i.test(s)) return 'fortinet';
// Palo Alto Networks PAN-OS
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
return '';
};
/**
* Classify a distro/vendor ID into a high-level device class. Features that
* assume a POSIX shell (periodic stats polling, /etc/os-release probing, etc.)
* should only run when this returns `linux-like`.
*/
export type DeviceClass = 'linux-like' | 'network-device' | 'other';
export const classifyDistroId = (distroId?: string): DeviceClass => {
const v = (distroId || '').toLowerCase().trim();
if (!v) return 'other';
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return 'network-device';
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
return 'other';
};
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {

View File

@@ -62,7 +62,7 @@ export interface Host {
id: string;
label: string;
hostname: string;
port: number;
port?: number;
username: string;
// Optional reference to a reusable identity (username + auth) stored in Keychain.
identityId?: string;
@@ -95,6 +95,8 @@ export interface Host {
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
fontSize?: number; // Terminal font size for this host (pt)
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
fontWeight?: number; // Terminal font weight for this host (100-900)
fontWeightOverride?: boolean; // Explicitly override the global terminal font weight for this host
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
@@ -117,9 +119,20 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';
// Local SSH key file paths (from SSH config IdentityFile or user-added)
// Resolved at connection time — the app reads the file content when connecting.
identityFilePaths?: string[];
// Pin host to top of All hosts view for quick access
pinned?: boolean;
// Timestamp of last successful connection, used for Recently Connected section
lastConnectedAt?: number;
// Per-session shell override for local terminals (from shell discovery)
localShell?: string;
localShellArgs?: string[];
localShellName?: string;
localShellIcon?: string;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -178,6 +191,42 @@ export interface GroupNode {
totalHostCount?: number;
}
/** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */
export interface GroupConfig {
path: string;
username?: string;
password?: string;
savePassword?: boolean;
authMethod?: 'password' | 'key' | 'certificate';
identityId?: string;
identityFileId?: string;
identityFilePaths?: string[];
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
legacyAlgorithms?: boolean;
environmentVariables?: EnvVar[];
charset?: string;
moshEnabled?: boolean;
moshServerPath?: string;
telnetEnabled?: boolean;
telnetPort?: number;
telnetUsername?: string;
telnetPassword?: string;
theme?: string;
themeOverride?: boolean;
fontFamily?: string;
fontFamilyOverride?: boolean;
fontSize?: number;
fontSizeOverride?: boolean;
fontWeight?: number;
fontWeightOverride?: boolean;
backspaceBehavior?: 'ctrl-h';
}
export interface SyncConfig {
gistId: string;
githubToken: string;
@@ -347,7 +396,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
// Navigation / Split View
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
@@ -451,7 +500,7 @@ export interface TerminalSettings {
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
rendererType: 'auto' | 'webgl' | 'dom'; // Terminal renderer: auto (detect based on hardware), webgl, or dom
// Autocomplete
autocompleteEnabled: boolean; // Enable terminal command autocomplete
@@ -527,8 +576,14 @@ export const normalizeTerminalSettings = (
...(settings ?? {}),
};
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
const rendererType = (mergedSettings.rendererType as string) === 'canvas'
? 'dom' as const
: mergedSettings.rendererType;
return {
...mergedSettings,
rendererType,
autocompleteGhostText: mergedSettings.autocompletePopupMenu
? false
: mergedSettings.autocompleteGhostText,
@@ -626,6 +681,10 @@ export interface TerminalSession {
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
// Serial-specific connection settings
serialConfig?: SerialConfig;
localShell?: string; // Shell command for local terminals (from discovery)
localShellArgs?: string[]; // Shell args for local terminals (from discovery)
localShellName?: string; // Display name for local shell (e.g., "Zsh", "Ubuntu (WSL)")
localShellIcon?: string; // Icon identifier for local shell (e.g., "zsh", "ubuntu")
}
export interface RemoteFile {

View File

@@ -165,6 +165,9 @@ export interface SyncPayload {
customGroups: string[];
snippetPackages?: string[];
// Group configs (connection defaults per host group)
groupConfigs?: import('./models').GroupConfig[];
// Port forwarding rules
portForwardingRules?: import('./models').PortForwardingRule[];
@@ -201,6 +204,12 @@ export interface SyncPayload {
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Immersive mode
immersiveMode?: boolean;
// Vault: show recently connected hosts
showRecentHosts?: boolean;
// Vault: root list shows only ungrouped hosts
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
};
// Sync metadata

View File

@@ -384,8 +384,17 @@ export function mergeSyncPayloads(
remote.portForwardingRules ?? [],
);
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
type GCWithId = import('./models').GroupConfig & { id: string };
const wrapGC = (arr: import('./models').GroupConfig[] | undefined): GCWithId[] =>
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
// Aggregate stats
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -430,6 +439,7 @@ export function mergeSyncPayloads(
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
settings,
syncedAt: Date.now(),
};

View File

@@ -41,9 +41,49 @@ export const clearHostFontSizeOverride = (host: Host): Host => ({
export const resolveHostTerminalThemeId = (host: Host | null | undefined, defaultThemeId: string): string =>
hasHostThemeOverride(host) && host?.theme ? host.theme : defaultThemeId;
/**
* Map a UI theme preset ID to the terminal theme whose background matches
* it exactly. Used when "Follow Application Theme" is enabled so the
* terminal blends seamlessly with the app chrome. Returns undefined if no
* match exists (caller should fall back to the global terminal theme).
*/
const UI_TO_TERMINAL_THEME: Record<string, string> = {
// Light
'snow': 'ui-snow',
'pure-white': 'ui-pure-white',
'ivory': 'ui-ivory',
'mist': 'ui-mist',
'mint': 'ui-mint',
'sand': 'ui-sand',
'lavender': 'ui-lavender',
// Dark
'pure-black': 'ui-pure-black',
'midnight': 'ui-midnight',
'deep-blue': 'ui-deep-blue',
'vscode': 'ui-vscode',
'graphite': 'ui-graphite',
'obsidian': 'ui-obsidian',
'forest': 'ui-forest',
};
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));
export const clearHostFontWeightOverride = (host: Host): Host => ({
...host,
fontWeight: undefined,
fontWeightOverride: false,
});
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;

View File

@@ -155,6 +155,7 @@ const createHost = (input: {
label?: string;
hostname: string;
username?: string;
password?: string;
port?: number;
protocol?: Exclude<HostProtocol, "mosh">;
group?: string;
@@ -167,6 +168,7 @@ const createHost = (input: {
hostname: input.hostname.trim(),
port: input.port ?? DEFAULT_SSH_PORT,
username: input.username?.trim() ?? "",
password: input.password || undefined,
group: normalizeGroupPath(input.group),
tags: (input.tags ?? []).filter(Boolean),
os: "linux",
@@ -189,6 +191,7 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
duplicates++;
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
existing.tags = mergedTags;
if (!existing.password && host.password) existing.password = host.password;
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
@@ -333,6 +336,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
const passwordIdx = findHeaderIndex(header, ["password", "pass", "passwd"]);
if (hostnameIdx === -1) {
return {
@@ -378,12 +382,14 @@ const importFromCsv = (text: string): VaultImportResult => {
"ssh";
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
parsedHosts.push(
createHost({
label,
hostname: target.hostname,
username,
password,
port,
protocol,
group,
@@ -993,12 +999,12 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root"]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu"]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin"]);
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
}
const escapeCsv = (value: string) => {
@@ -1011,13 +1017,14 @@ export const getVaultCsvTemplate = (
};
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
const escapeCsv = (value: string, skipFormulaGuard = false) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
// Skip for password fields to preserve credentials verbatim for round-trip
if (!skipFormulaGuard && /^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
@@ -1059,10 +1066,12 @@ const exportHostsToCsv = (hosts: Host[]): string => {
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
host.password ?? "",
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
const passwordColIdx = header.indexOf("Password");
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(c, rowIdx > 0 && i === passwordColIdx)).join(",")).join("\r\n") + "\r\n";
};
interface ExportHostsResult {

View File

@@ -21,6 +21,7 @@ module.exports = {
'electron/**/*',
'lib/**/*.cjs',
'!electron/.dev-config.json',
'skills/**/*',
'public/**/*',
'node_modules/**/*'
],
@@ -41,7 +42,10 @@ module.exports = {
'node_modules/fast-deep-equal/**/*',
'node_modules/fast-uri/**/*',
'node_modules/json-schema-traverse/**/*',
'electron/cli/**/*',
'electron/mcp/**/*'
,
'skills/**/*'
],
mac: {
target: [
@@ -83,9 +87,16 @@ module.exports = {
{
target: 'nsis',
arch: ['x64', 'arm64']
},
{
target: 'portable',
arch: ['x64', 'arm64']
}
]
},
portable: {
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
},
nsis: {
oneClick: false,
perMachine: false,

View File

@@ -8,7 +8,8 @@
const { execFileSync } = require("node:child_process");
const { createHash } = require("node:crypto");
const { existsSync } = require("node:fs");
const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
@@ -82,13 +83,13 @@ function resolveCodexAcpBinaryPath(shellEnv, electronModule) {
// Packaged build (or dev fallback): use npm-bundled binary
try {
const pkgName = getCodexPackageName();
if (!pkgName) return binaryName;
if (!pkgName) return null;
const pkgRoot = path.dirname(require.resolve("@zed-industries/codex-acp/package.json"));
const resolved = require.resolve(`${pkgName}/bin/${binaryName}`, { paths: [pkgRoot] });
return toUnpackedAsarPath(resolved);
} catch {
return binaryName;
return null;
}
}
@@ -124,6 +125,212 @@ function getActiveCodexLoginSession() {
return null;
}
// ── Codex config.toml probing ──
//
// Users who hand-configure `~/.codex/config.toml` with a custom
// `model_provider` + matching `[model_providers.<name>]` entry are fully
// functional from the Codex CLI, but `codex login status` doesn't see them
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
// We read and minimally parse the config file so we can surface this as a
// valid "ready" state and skip the ChatGPT login prompt in the UI.
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
* than peeking at the previous character, so even runs of backslashes like
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
* strings don't recognize `\` as an escape, so only honor escapes inside
* basic (double-quoted) strings. */
function findUnquotedHash(value) {
let inStr = false;
let quote = "";
let escaped = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (inStr) {
if (escaped) {
escaped = false;
continue;
}
if (quote === '"' && ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
inStr = false;
quote = "";
}
continue;
}
if (ch === '"' || ch === "'") {
inStr = true;
quote = ch;
continue;
}
if (ch === "#") return i;
}
return -1;
}
/**
* Parse the narrow subset of TOML we need from Codex's config.toml:
* - top-level string keys (e.g. `model_provider = "my_provider"`)
* - `[model_providers.<name>]` tables with string-valued keys
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
* are ignored — Codex's config.toml doesn't use them for provider definitions.
*/
function parseCodexConfigToml(text) {
const result = { model_providers: {} };
let currentProvider = null;
let atTopLevel = true;
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
const normalized = String(text || "").replace(/^\uFEFF/, "");
const lines = normalized.split(/\r?\n/);
for (const rawLine of lines) {
let line = rawLine;
const hashIdx = findUnquotedHash(line);
if (hashIdx >= 0) line = line.slice(0, hashIdx);
line = line.trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const section = sectionMatch[1].trim();
if (section.startsWith("model_providers.")) {
currentProvider = section.slice("model_providers.".length);
if (!result.model_providers[currentProvider]) {
result.model_providers[currentProvider] = {};
}
atTopLevel = false;
} else {
currentProvider = null;
atTopLevel = false;
}
continue;
}
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
if (!kvMatch) continue;
const key = kvMatch[1];
let raw = kvMatch[2].trim();
let value;
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
value = raw.slice(1, -1);
} else {
value = raw;
}
if (atTopLevel) {
result[key] = value;
} else if (currentProvider) {
result.model_providers[currentProvider][key] = value;
}
}
return result;
}
/**
* Inspect `~/.codex/config.toml` to determine whether the user has
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
* path.
*
* Returns null when:
* - the config file doesn't exist or can't be read
* - no `model_provider` is set, or it points to the default `openai` preset
* - the referenced provider entry is missing (config is malformed)
*
* Returns a summary object otherwise — even if the env_key isn't currently
* exported in the shell environment. That case is surfaced via
* `envKeyPresent: false` so the UI can warn the user; we don't want the
* absence of an env var to silently fall back to the ChatGPT login flow,
* because the config.toml is a strong signal the user doesn't want that.
*/
function readCodexCustomProviderConfig(shellEnv) {
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
if (!home) return null;
const configPath = path.join(home, ".codex", "config.toml");
if (!existsSync(configPath)) return null;
let text;
try {
text = readFileSync(configPath, "utf8");
} catch {
return null;
}
let parsed;
try {
parsed = parseCodexConfigToml(text);
} catch {
return null;
}
const activeName = typeof parsed.model_provider === "string"
? parsed.model_provider.trim()
: "";
if (!activeName) return null;
// The built-in "openai" provider still goes through ChatGPT/API-key auth
// managed by `codex login`, so treating it as "custom" would be wrong.
if (activeName === "openai") return null;
const providerEntry = parsed.model_providers?.[activeName];
if (!providerEntry) return null;
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
// Hash the actual auth material (either the hardcoded api_key or the
// resolved env_key value) so the ACP provider fingerprint changes when
// the user rotates their key — without ever returning the raw value
// across the IPC boundary.
const authMaterial = hardcodedApiKey || envKeyValue;
const authHash = authMaterial
? createHash("sha256").update(authMaterial).digest("hex")
: null;
return {
providerName: activeName,
displayName: providerEntry.name || activeName,
baseUrl: providerEntry.base_url || null,
envKey: envKeyName || null,
envKeyPresent: Boolean(envKeyValue),
hasHardcodedApiKey: Boolean(hardcodedApiKey),
model: activeModel || null,
authHash,
};
}
/**
* Returns a user-facing error message when a Codex config.toml custom
* provider references an env_key that isn't exported in the shell env and
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
* by every spawn path (stream handler, list-models handler) so users get
* the same actionable message regardless of which one hits first.
*/
function getCodexCustomConfigPreflightError(customConfig) {
if (!customConfig) return null;
if (!customConfig.envKey) return null;
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
}
/**
* Compute the ACP auth override object for Codex spawn sites.
* - netcatty-managed API key present → "codex-api-key"
* - user's own ~/.codex/config.toml custom provider detected → no override
* (so codex-acp resolves auth from the shell env / config itself)
* - otherwise → "chatgpt" (triggers the browser OAuth login flow)
*
* Returned as an object designed to be spread into createACPProvider options.
*/
function getCodexAuthOverride(apiKey, shellEnv) {
if (apiKey) return { authMethodId: "codex-api-key" };
if (readCodexCustomProviderConfig(shellEnv)) return {};
return { authMethodId: "chatgpt" };
}
// ── Integration state ──
function normalizeCodexIntegrationState(rawOutput) {
@@ -199,6 +406,9 @@ module.exports = {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,

View File

@@ -145,6 +145,668 @@ function findEndMarker(outputText, marker) {
return null;
}
function normalizePtyOutput(stdout, {
stripMarkers = false,
expectedPrompt = "",
trimOutput = true,
stripPrompt = true,
markerToStrip = null,
} = {}) {
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
// Prefer the job-specific marker so user output that contains "__NCMCP_"
// (e.g. printf '__NCMCP_demo\n') is preserved.
const pattern = markerToStrip
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
cleaned = cleaned.replace(pattern, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
return trimOutput ? cleaned.trim() : cleaned;
}
function appendBoundedOutput(current, chunk, maxBufferedChars) {
const combined = `${current || ""}${chunk || ""}`;
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
if (limit <= 0 || combined.length <= limit) {
return { text: combined, dropped: 0 };
}
const dropped = combined.length - limit;
return {
text: combined.slice(dropped),
dropped,
};
}
function consumeVisibleText(carry, chunk) {
const input = `${carry || ""}${chunk || ""}`;
if (!input) {
return { visibleText: "", carry: "" };
}
let visibleText = "";
let index = 0;
while (index < input.length) {
const ch = input[index];
if (ch === "\r") {
// Preserve \r so consumers / serializers can collapse progress-bar
// redraws to the latest frame. \r\n becomes a single \n.
if (input[index + 1] === "\n") {
visibleText += "\n";
index += 2;
continue;
}
visibleText += "\r";
index += 1;
continue;
}
if (ch !== "\u001b") {
visibleText += ch;
index += 1;
continue;
}
if (index + 1 >= input.length) {
break;
}
const next = input[index + 1];
if (next === "[") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const code = input.charCodeAt(cursor);
if (code >= 0x40 && code <= 0x7e) {
index = cursor + 1;
complete = true;
break;
}
cursor += 1;
}
if (!complete) break;
continue;
}
if (next === "]") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const oscChar = input[cursor];
if (oscChar === "\u0007") {
index = cursor + 1;
complete = true;
break;
}
if (oscChar === "\u001b") {
if (cursor + 1 >= input.length) break;
if (input[cursor + 1] === "\\") {
index = cursor + 2;
complete = true;
break;
}
}
cursor += 1;
}
if (!complete) break;
continue;
}
visibleText += ch;
index += 1;
}
return {
visibleText,
carry: input.slice(index),
};
}
function startPtyJob(ptyStream, command, options) {
const {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
typedInput = false,
echoCommand,
maxBufferedChars = 0,
normalizeFinalOutput = true,
enforceWallTimeout = false,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
const CANCEL_RETRY_MS = 5000;
const CANCEL_WALL_TIMEOUT_MS = 30000;
let output = "";
let foundStart = false;
let preStartOutput = "";
let visibleOutput = "";
let visibleOutputOffset = 0;
// Monotonic high-water mark for the visible byte stream. Increases on every
// append; never decreases when CR redraws collapse visibleOutput. Used as
// the polling nextOffset so callers' offsets stay monotonic.
let visibleHighWatermark = 0;
let visibleCarry = "";
let timeoutId = null;
let wallTimeoutId = null;
let startupTimeoutId = null;
let promptFallbackTimer = null;
let cancelRetryTimerId = null;
// Track one-shot timers scheduled inside requestCancel so finish() can
// clear them when the job exits early; otherwise they keep the Node
// event loop alive after the resultPromise has already resolved.
const cancelOneShotTimers = [];
let cancelRequested = false;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
let pendingStart = "";
let resolveResult;
const resultPromise = new Promise((resolve) => {
resolveResult = resolve;
});
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function clearCancelRetryTimer() {
if (cancelRetryTimerId) {
clearTimeout(cancelRetryTimerId);
cancelRetryTimerId = null;
}
}
function armOutputTimeout() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
sendInterrupt();
if (cancelRequested) {
armOutputTimeout();
return;
}
const timeoutSec = Math.round(timeoutMs / 1000);
finish(foundStart ? output : preStartOutput, -1, `Command timed out after ${timeoutSec}s without output`);
}, timeoutMs);
}
// Hard wall-clock deadline: opt-in via enforceWallTimeout. Used by callers
// that have a strict tool-call budget (e.g. MCP terminal_execute, where the
// model can fall back to terminal_start). Default is off so existing
// foreground execution paths (Catty Agent) keep their inactivity-based
// timeout for long-running streaming commands.
function armWallTimeout() {
if (!enforceWallTimeout || maxBufferedChars > 0) return;
wallTimeoutId = setTimeout(() => {
if (finished) return;
sendInterrupt();
const timeoutSec = Math.round(timeoutMs / 1000);
finish(foundStart ? output : preStartOutput, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
}
// Bounded startup deadline: we always need a hard limit on how long we
// wait for the wrapped command's start marker. Otherwise an already-chatty
// PTY (e.g. a tab running tail -f) would let onData re-arm the inactivity
// timer forever before _S arrives, hanging the call and the session lock.
// Foreground execs use the configured timeoutMs as the deadline (matching
// the pre-PR behavior); background jobs use a fixed 30s since their main
// timeout is much longer (1 hour) and meant for the actual command.
const BG_STARTUP_TIMEOUT_MS = 30000;
function armStartupTimeout() {
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
startupTimeoutId = setTimeout(() => {
if (finished || foundStart) return;
sendInterrupt();
const label = maxBufferedChars > 0 ? "Background job startup" : "Command startup";
finish(preStartOutput, -1, `${label} timed out — start marker never arrived`);
}, startupMs);
}
function clearStartupTimeout() {
if (startupTimeoutId) {
clearTimeout(startupTimeoutId);
startupTimeoutId = null;
}
}
function sendInterrupt() {
try {
if (typeof ptyStream.signal === "function") {
ptyStream.signal("INT");
}
} catch {
// Ignore signal failures and fall back to ETX.
}
try {
if (typeof ptyStream.write === "function") {
ptyStream.write("\x03");
}
} catch {
// Ignore PTY write failures during cancellation.
}
}
function requestCancel() {
if (finished || cancelRequested) return;
cancelRequested = true;
clearPromptFallback();
clearCancelRetryTimer();
// Cancel the startup timer too — otherwise a pre-start cancel resolves
// as "Background job startup timed out" instead of "Cancelled".
clearStartupTimeout();
// For pre-start cancellation on sessions without a known idle prompt,
// schedule a short fallback to finish the job after Ctrl+C has had time
// to take effect. Without this, the cancel waits the full forced-cancel
// window even though the shell may have returned to idle quickly.
if (!foundStart && !expectedPrompt) {
const t = setTimeout(() => {
if (finished || foundStart) return;
finish(preStartOutput, 130, "Cancelled");
}, 2000);
cancelOneShotTimers.push(t);
}
sendInterrupt();
cancelRetryTimerId = setTimeout(function retryCancel() {
if (finished || !cancelRequested) return;
sendInterrupt();
cancelRetryTimerId = setTimeout(retryCancel, CANCEL_RETRY_MS);
}, CANCEL_RETRY_MS);
armOutputTimeout();
const t150 = setTimeout(() => {
if (!finished) sendInterrupt();
}, 150);
cancelOneShotTimers.push(t150);
// Hard wall-clock deadline for cancellation: if the process ignores
// Ctrl+C and never redraws the prompt, force-finish after a bounded
// period so the session is not stuck in "stopping" forever.
// Mark as "forced" so callers can tell the shell may still be busy.
const tWall = setTimeout(() => {
if (!finished) {
finish(foundStart ? output : preStartOutput, 130, "Cancelled (forced — process may still be running)");
}
}, CANCEL_WALL_TIMEOUT_MS);
cancelOneShotTimers.push(tWall);
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Background jobs use a much longer delay (30s) so commands that open
// child shells / REPLs with the same prompt have time to print past
// their initial prompt and avoid being misdetected as completed.
// Foreground execs use 250ms to match the pre-PR behavior.
const delayMs = maxBufferedChars > 0 ? 30000 : 250;
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, delayMs);
}
function checkEnd() {
const found = findEndMarker(output, marker);
if (!found) return;
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
}
// Carry buffer for incomplete marker lines split across chunks.
let visibleMarkerCarry = "";
// Note: we intentionally do NOT collapse CR redraws in visibleOutput.
// Doing so makes polling offsets non-monotonic and can drop finalized
// lines after a CR rewrite. Instead, the buffer stores raw bytes
// (including \r) and the bounded-buffer cap (256KB) keeps progress-bar
// accumulation under control. Consumers that want a "collapsed" view
// can apply CR processing themselves.
function appendToVisible(text) {
if (!text) return;
const normalized = consumeVisibleText(visibleCarry, text);
visibleCarry = normalized.carry;
if (!normalized.visibleText) return;
let cleanVisible = normalized.visibleText;
if (maxBufferedChars > 0) {
// Rejoin with any incomplete line from the previous chunk so marker
// lines split across PTY data boundaries are matched as a whole.
cleanVisible = visibleMarkerCarry + cleanVisible;
visibleMarkerCarry = "";
// We must withhold any trailing line that *might* be the start of an
// internal marker line, even if the random marker token isn't fully
// present yet (the chunk boundary may split the marker mid-token).
// Detect this by looking for the constant prefix "__NCMCP_" — only
// user output that *contains an unrelated __NCMCP_ string and ends
// with a newline* will be preserved through the next strip step.
const NCMCP_PREFIX = "__NCMCP_";
const lastNl = cleanVisible.lastIndexOf("\n");
if (lastNl === -1) {
if (cleanVisible.includes(NCMCP_PREFIX)) {
visibleMarkerCarry = cleanVisible;
return;
}
} else if (lastNl < cleanVisible.length - 1) {
const trailing = cleanVisible.slice(lastNl + 1);
if (trailing.includes(NCMCP_PREFIX)) {
visibleMarkerCarry = trailing;
cleanVisible = cleanVisible.slice(0, lastNl + 1);
}
}
// Strip only this job's specific marker lines so user output that
// happens to contain "__NCMCP_" (e.g. printf '__NCMCP_demo\n') is
// preserved.
cleanVisible = cleanVisible.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
if (!cleanVisible) return;
}
visibleHighWatermark += cleanVisible.length;
const next = appendBoundedOutput(visibleOutput, cleanVisible, maxBufferedChars);
visibleOutput = next.text;
visibleOutputOffset += next.dropped;
}
function appendToOutput(text) {
if (!text) return;
const next = appendBoundedOutput(output, text, maxBufferedChars);
output = next.text;
appendToVisible(text);
}
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearTimeout(wallTimeoutId);
clearStartupTimeout();
clearPromptFallback();
clearCancelRetryTimer();
// Clear any pending one-shot cancel timers so they do not keep the
// Node event loop alive after the job has resolved.
while (cancelOneShotTimers.length) {
clearTimeout(cancelOneShotTimers.pop());
}
unsubscribe?.();
for (const fn of cleanupFns) {
try {
fn();
} catch {
// Ignore cleanup failures
}
}
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
// Flush any incomplete marker carry — if it wasn't this job's marker, append it.
if (visibleMarkerCarry) {
const leftover = visibleMarkerCarry.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
visibleMarkerCarry = "";
if (leftover) {
const next = appendBoundedOutput(visibleOutput, leftover, maxBufferedChars);
visibleOutput = next.text;
visibleOutputOffset += next.dropped;
}
}
// For background jobs (maxBufferedChars > 0), use the already-stripped
// visibleOutput so completion offsets are consistent with polling offsets.
// Re-normalizing from the raw buffer would produce a shorter result because
// ANSI codes inflate the raw buffer, causing it to truncate earlier.
let cleaned;
let outputBaseOffset;
let totalOutputChars;
if (maxBufferedChars > 0 && foundStart) {
// Always strip this job's markers from the visible buffer — it accumulates
// raw PTY data including the end-marker line that must not leak to callers.
const strippedVisible = normalizePtyOutput(visibleOutput, {
stripMarkers: true,
markerToStrip: marker,
expectedPrompt,
trimOutput: normalizeFinalOutput,
stripPrompt: true,
});
cleaned = strippedVisible;
outputBaseOffset = visibleOutputOffset;
totalOutputChars = outputBaseOffset + visibleOutput.length;
} else {
const visibleStdout = normalizePtyOutput(stdout, {
stripMarkers,
markerToStrip: marker,
expectedPrompt,
trimOutput: false,
stripPrompt: true,
});
cleaned = normalizeFinalOutput
? normalizePtyOutput(stdout, {
stripMarkers,
markerToStrip: marker,
expectedPrompt,
trimOutput: true,
stripPrompt: true,
})
: visibleStdout;
outputBaseOffset = foundStart ? visibleOutputOffset : 0;
totalOutputChars = outputBaseOffset + visibleStdout.length;
}
const finalError = (!error && cancelRequested) ? "Cancelled" : error;
const finalExitCode = finalError === "Cancelled" ? (exitCode ?? 130) : exitCode;
if (finalError) {
resolveResult({
ok: false,
stdout: cleaned,
stderr: "",
exitCode: finalExitCode ?? -1,
error: finalError,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
});
} else {
resolveResult({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: finalExitCode ?? 0,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
});
}
}
function onData(data) {
const text = data.toString();
armOutputTimeout();
if (!foundStart) {
preStartOutput += text;
// Cap preStartOutput for background jobs so a noisy idle PTY can't
// accumulate megabytes before the start marker arrives. We only need
// enough tail to find the marker boundary.
if (maxBufferedChars > 0 && preStartOutput.length > maxBufferedChars) {
preStartOutput = preStartOutput.slice(preStartOutput.length - maxBufferedChars);
}
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
const lines = combined.split(/\r?\n/);
const trailingPartial = /[\r\n]$/.test(combined) ? "" : lines.pop() || "";
for (const line of lines) {
if (stripAnsi(line).trim() === startMarker) {
foundStart = true;
matched = true;
break;
}
}
pendingStart = trailingPartial;
if (foundStart) {
clearStartupTimeout();
// Use the *last* occurrence of the start marker to skip the echoed
// wrapper command and capture only output after the real printf line.
const markerPattern = new RegExp(`${marker}_S[^\n\r]*(?:\r?\n|$)`, "g");
let boundary = -1;
let m;
while ((m = markerPattern.exec(preStartOutput)) !== null) {
boundary = m.index;
}
if (boundary !== -1) {
const afterBoundary = preStartOutput.slice(boundary);
const firstNl = afterBoundary.search(/\r?\n/);
const initialOutput = firstNl === -1 ? "" : afterBoundary.slice(firstNl).replace(/^\r?\n/, "");
output = "";
visibleOutput = "";
visibleOutputOffset = 0;
visibleCarry = "";
appendToOutput(initialOutput);
}
preStartOutput = "";
schedulePromptFallback();
checkEnd();
return;
}
if (!matched) {
const fallbackEnd = findEndMarker(preStartOutput, marker);
if (fallbackEnd) {
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
const lastStartIdx = stdout.lastIndexOf(startMarker);
if (lastStartIdx !== -1) {
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
if (nlAfterStart !== -1) {
stdout = stdout.slice(nlAfterStart + 1);
}
}
finish(stdout, fallbackEnd.exitCode);
return;
}
}
// If we're cancelling a still-queued command and the shell has returned
// to its idle prompt, finish immediately as Cancelled instead of waiting
// for the cancel wall-clock timer.
if (cancelRequested && hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) {
finish(preStartOutput, 130, "Cancelled");
return;
}
return;
}
appendToOutput(text);
if (!cancelRequested) {
schedulePromptFallback();
} else if (hasExpectedPromptSuffix(output, expectedPrompt)) {
finish(output, 130, "Cancelled");
return;
}
checkEnd();
}
if (abortSignal?.aborted) {
finish("", -1, "Cancelled");
return {
marker,
cancel: () => {},
getSnapshot: () => ({ stdout: "", status: "cancelled", foundStart: false }),
resultPromise,
};
}
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
unsubscribe = subscribeToPtyData(ptyStream, onData);
const cancel = () => {
requestCancel();
};
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel,
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
},
});
}
if (typeof ptyStream.on === "function") {
const onClose = () => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly");
const onError = (err) => finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch {}
try { ptyStream.removeListener("end", onClose); } catch {}
try { ptyStream.removeListener("error", onError); } catch {}
});
}
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"));
cleanupFns.push(() => {
try {
disposable?.dispose?.();
} catch {
// Ignore cleanup failures
}
});
}
if (abortSignal) {
const onAbort = () => {
requestCancel();
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
return {
marker,
cancel,
// Until the start marker arrives, return empty stdout/zero offsets so
// an early poll cannot advance nextOffset past pre-start PTY noise that
// gets discarded once the real command begins.
getSnapshot: () => ({
stdout: foundStart ? visibleOutput : "",
outputBaseOffset: foundStart ? visibleOutputOffset : 0,
totalOutputChars: foundStart ? visibleOutputOffset + visibleOutput.length : 0,
outputTruncated: foundStart ? visibleOutputOffset > 0 : false,
status: finished ? "finished" : (cancelRequested ? "stopping" : "running"),
foundStart,
}),
resultPromise,
};
}
/**
* Execute command through a terminal PTY stream.
* The user sees the command typed and output in their terminal.
@@ -163,228 +825,7 @@ function findEndMarker(outputText, marker) {
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
*/
function execViaPty(ptyStream, command, options) {
const {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
typedInput = false,
echoCommand,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
// Fast-path: already aborted before we even start
if (abortSignal?.aborted) {
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
}
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let preStartOutput = "";
let timeoutId = null;
let promptFallbackTimer = null;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
// Buffer for incomplete line data when searching for start marker.
// SSH channels can split data at arbitrary byte boundaries, so the
// start marker may arrive across two chunks. We keep the content
// after the last \n (i.e. the current incomplete line) and prepend
// it to the next chunk so indexOf can match the full marker.
let pendingStart = "";
const onData = (data) => {
const text = data.toString();
if (!foundStart) {
preStartOutput += text;
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
let pos = 0;
while (pos < combined.length) {
const idx = combined.indexOf(startMarker, pos);
if (idx === -1) break;
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
foundStart = true;
matched = true;
const afterMarker = combined.slice(idx);
const nlIdx = afterMarker.indexOf("\n");
if (nlIdx !== -1) {
output += afterMarker.slice(nlIdx + 1);
}
break;
}
pos = idx + 1;
}
if (!matched) {
// Keep the last incomplete line for cross-chunk matching
const lastNl = combined.lastIndexOf("\n");
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
}
if (foundStart) {
preStartOutput = "";
schedulePromptFallback();
checkEnd();
return;
}
// Fallback: if strict start-marker detection missed (e.g. due shell
// control sequence prefixes), still complete as soon as we observe a
// valid end marker with exit code.
const fallbackEnd = findEndMarker(preStartOutput, marker);
if (fallbackEnd) {
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
const lastStartIdx = stdout.lastIndexOf(startMarker);
if (lastStartIdx !== -1) {
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
if (nlAfterStart !== -1) {
stdout = stdout.slice(nlAfterStart + 1);
}
}
finish(stdout, fallbackEnd.exitCode);
}
return;
}
output += text;
schedulePromptFallback();
checkEnd();
};
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Fallback for shells that visibly return to the same idle prompt but
// never emit the wrapped end marker line.
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, 250);
}
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const found = findEndMarker(output, marker);
if (!found) return;
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
}
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearPromptFallback();
unsubscribe?.();
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
cleaned = cleaned.trim();
if (error) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
} else {
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
}
timeoutId = setTimeout(() => {
// Send Ctrl+C to kill the timed-out command
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
const timeoutSec = Math.round(timeoutMs / 1000);
finish(output, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
unsubscribe = subscribeToPtyData(ptyStream, onData);
// Register for cancellation if tracking map provided
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel: () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
},
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
},
});
}
// Stream close/error detection — resolve immediately instead of waiting for timeout
if (typeof ptyStream.on === "function") {
const onClose = () => finish(output, null, "Stream closed unexpectedly");
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
try { ptyStream.removeListener("error", onError); } catch { /* */ }
});
}
// node-pty uses onExit instead of close/end
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
}
// AbortSignal handling — send Ctrl+C and resolve when aborted
if (abortSignal) {
const onAbort = () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
// Markers are filtered from terminal display by preload.cjs.
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
return startPtyJob(ptyStream, command, options).resultPromise;
}
/**
@@ -659,6 +1100,7 @@ execViaRawPty._seq = 0;
module.exports = {
execViaPty,
startPtyJob,
execViaChannel,
execViaRawPty,
detectShellKind,

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