Compare commits

...

57 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
Eric Chan
58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
44 changed files with 2659 additions and 349 deletions

29
App.tsx
View File

@@ -307,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])),
@@ -893,13 +899,18 @@ function App({ settings }: { settings: SettingsState }) {
// 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]);
}
@@ -907,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) {
@@ -920,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) {
@@ -968,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':
@@ -1056,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) => {
@@ -1424,6 +1433,7 @@ function App({ settings }: { settings: SettingsState }) {
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
/>
<div className="flex-1 relative min-h-0">
@@ -1469,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)}
/>
@@ -1582,6 +1594,7 @@ function App({ settings }: { settings: SettingsState }) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {

View File

@@ -202,6 +202,10 @@ const en: Messages = {
'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',
@@ -1152,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',
@@ -1740,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:',
@@ -1756,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',
@@ -1789,6 +1796,17 @@ const en: Messages = {
'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.',
@@ -1843,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.',

View File

@@ -186,6 +186,10 @@ const zhCN: Messages = {
'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': '发现新版本',
@@ -765,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': '启用广播模式',
@@ -1748,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': '路径:',
@@ -1764,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',
@@ -1797,6 +1804,17 @@ const zhCN: Messages = {
'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 → 提供商** 添加并启用一个提供商。',
@@ -1851,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 开发进程,然后重试。',

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

@@ -34,7 +34,9 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
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';
@@ -71,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;
@@ -260,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;
@@ -463,6 +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);
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);
@@ -662,6 +685,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
});
@@ -671,6 +695,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
};
@@ -834,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';
@@ -923,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)
@@ -1228,6 +1292,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1266,6 +1336,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
]),
};

View File

@@ -43,6 +43,8 @@ import {
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -173,6 +175,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
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;
}
@@ -238,6 +244,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// 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);
}
}
// ---------------------------------------------------------------------------

View File

@@ -32,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';
@@ -39,6 +40,11 @@ import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import {
getReadyUserSkillOptions,
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -146,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') {
@@ -160,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}`,
}));
}
@@ -248,6 +254,8 @@ 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();
@@ -414,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(() => {
@@ -458,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);
@@ -480,13 +537,58 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => 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;
if (!isCopilotExternalAgent) 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;
@@ -500,6 +602,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
`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,
@@ -518,12 +633,28 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
);
// 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(() => {
@@ -560,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,
@@ -568,6 +705,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
setActiveSessionId,
setInputValue,
scopeKey,
]);
const handleOpenSettings = useCallback(() => {
@@ -610,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)) {
@@ -649,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';
@@ -675,6 +849,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
setInputValue('');
clearFiles();
clearSelectedUserSkills();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -708,6 +883,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
@@ -735,6 +911,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
@@ -746,6 +923,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
selectedUserSkillSlugs, clearSelectedUserSkills,
]);
const handleStop = useCallback(() => {
@@ -908,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

@@ -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

@@ -70,6 +70,7 @@ interface QuickSwitcherProps {
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> = ({
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose,
onCreateLocalTerminal,
keyBindings,
showSftpTab,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -161,7 +163,7 @@ 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 }),
);
@@ -194,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
@@ -317,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 =

View File

@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
</div>
);
}
return this.props.children;
return (this.props as { children: React.ReactNode }).children;
}
}
@@ -286,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
showRecentHosts={settings.showRecentHosts}
setShowRecentHosts={settings.setShowRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
/>
)}

View File

@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
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";
@@ -245,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>("");
@@ -684,6 +690,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const teardown = () => {
retryTokenRef.current = null;
cleanupSession();
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
@@ -989,7 +996,7 @@ 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.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
@@ -1398,6 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCancelConnect = () => {
retryTokenRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
@@ -1417,6 +1425,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCloseDisconnectedSession = () => {
retryTokenRef.current = null;
onCloseSession?.(sessionId);
};
@@ -1458,10 +1467,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
// Reset terminal state: disable mouse tracking modes and clear screen so
// stale SGR mouse sequences don't leak into the new session as text input.
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
termRef.current.reset();
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;
@@ -1470,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"

View File

@@ -33,6 +33,7 @@ import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKe
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
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';
@@ -1646,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);

View File

@@ -44,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
@@ -251,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
showSftpTab,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -812,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 */}
@@ -969,7 +973,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
prev.isImmersiveActive === next.isImmersiveActive
prev.isImmersiveActive === next.isImmersiveActive &&
prev.showSftpTab === next.showSftpTab
);
};

View File

@@ -39,7 +39,11 @@ import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig"
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
import {
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
@@ -147,6 +151,8 @@ interface VaultViewProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
@@ -193,6 +199,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onRunSnippet,
groupConfigs,
onUpdateGroupConfigs,
showRecentHosts,
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
}) => {
@@ -230,11 +238,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
// Handle external navigation requests
useEffect(() => {
if (navigateToSection) {
@@ -874,6 +877,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return hostGroup === selectedGroupPath;
});
} else if (showOnlyUngroupedHostsInRoot) {
filtered = filtered.filter((h) => {
const hostGroup = (h.group || "").trim();
return hostGroup === "";
});
}
if (search.trim()) {
const s = search.toLowerCase();
@@ -911,7 +919,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
});
return filtered;
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
}, [hosts, selectedGroupPath, showOnlyUngroupedHostsInRoot, search, selectedTags, sortMode]);
// Pinned hosts for root-level display (not inside a subgroup)
// Respects active search and tag filters
@@ -962,6 +970,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// No longer deduplicate pinned/recent hosts from the main list,
// so hosts always appear in their groups regardless of pinned/recent status.
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
const visibleDisplayedHosts = useMemo(
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
[displayedHosts, selectedGroupPath, pinnedRecentIds],
);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
@@ -1125,6 +1137,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
}, [buildGroupTree, selectedGroupPath, customGroups]);
const shouldHideEmptyRootHostsSection = useMemo(() => {
if (selectedGroupPath || viewMode === "tree") return false;
if (search.trim() || selectedTags.length > 0) return false;
if (visibleDisplayedHosts.length > 0) return false;
return (
displayedGroups.length > 0 ||
pinnedHosts.length > 0 ||
(showRecentHosts && recentHosts.length > 0)
);
}, [
selectedGroupPath,
viewMode,
search,
selectedTags.length,
visibleDisplayedHosts.length,
displayedGroups.length,
pinnedHosts.length,
showRecentHosts,
recentHosts.length,
]);
// Known Hosts callbacks - use refs to keep stable references
// Store latest values in refs so callbacks don't need to depend on them
@@ -2353,6 +2385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</section>
{!shouldHideEmptyRootHostsSection && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
@@ -2360,7 +2393,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
@@ -2622,7 +2655,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
{visibleDisplayedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -2754,6 +2787,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
)}
</section>
)}
</div>
{currentSection === "snippets" && (

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

@@ -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>
);
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
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>
);
})}
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<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

@@ -121,6 +121,17 @@ export interface PanelBridge extends NetcattyBridge {
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;
}
@@ -155,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>();
@@ -239,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. */
@@ -251,6 +302,7 @@ export interface SendToExternalContext {
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
}
// -------------------------------------------------------------------
@@ -542,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)}`;
@@ -551,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 = () => {
@@ -637,19 +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 }));
@@ -683,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,
@@ -710,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

@@ -7,7 +7,7 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
@@ -25,6 +25,7 @@ import {
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
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";
@@ -32,6 +33,7 @@ import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
@@ -187,6 +189,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
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);
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
@@ -304,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));
@@ -425,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"
@@ -524,9 +572,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
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}
@@ -592,7 +639,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<div className="space-y-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-muted-foreground" />
<Link size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
</div>
@@ -614,6 +661,106 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</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

@@ -7,8 +7,6 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -27,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => 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,13 +51,14 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
} = props;
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -269,6 +274,21 @@ export default function SettingsAppearanceTab(props: {
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
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

@@ -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

@@ -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>;
}

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

@@ -328,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(
@@ -717,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.]");
@@ -756,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.]");
@@ -812,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(

View File

@@ -182,7 +182,11 @@ 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 = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -421,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());

View File

@@ -396,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' },

View File

@@ -206,6 +206,10 @@ export interface SyncPayload {
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

@@ -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");
@@ -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

@@ -211,6 +211,15 @@ async function getShellEnv() {
return _cachedShellEnv;
}
/**
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
* login shell. Useful when the user has just exported a new variable in
* their rc file and clicks "Refresh Status" without restarting the app.
*/
function invalidateShellEnvCache() {
_cachedShellEnv = null;
}
// ── Claude Code ACP binary resolution ──
/**
@@ -316,5 +325,6 @@ module.exports = {
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
};

View File

@@ -0,0 +1,511 @@
const fsPromises = require("node:fs/promises");
const path = require("node:path");
const USER_SKILLS_DIR_NAME = "Skills";
const USER_SKILLS_README_NAME = "README.txt";
const MAX_SKILL_BYTES = 24 * 1024;
const MAX_DESCRIPTION_LENGTH = 280;
const MAX_INDEX_SKILLS = 8;
const MAX_EXPLICIT_SKILLS = 4;
const MAX_MATCHED_SKILLS = 2;
const MAX_MATCHED_SKILL_CHARS = 6000;
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
const USER_SKILLS_README_CONTENT = [
"Netcatty user skills",
"",
"Add one folder per skill inside this directory.",
"Each skill folder must contain a SKILL.md file.",
"",
"Example layout:",
" Skills/",
" My Skill/",
" SKILL.md",
"",
"Minimal SKILL.md:",
" ---",
" name: My Skill",
" description: Short summary of what this skill helps with.",
" ---",
"",
" Write the skill instructions here.",
"",
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
"",
].join("\n");
const STOPWORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
"only", "your", "will", "should", "have", "has", "had", "using", "use",
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
]);
function stripQuotes(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function slugifySkill(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function tokenize(value) {
return String(value || "")
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatSkillReadWarning(error) {
const code = typeof error?.code === "string" ? error.code : null;
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
return code
? `Failed to read SKILL.md (${code}: ${message}).`
: `Failed to read SKILL.md (${message}).`;
}
function containsPlaintextPhrase(prompt, phrase) {
const trimmedPhrase = String(phrase || "").trim();
if (!trimmedPhrase) return false;
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
return pattern.test(String(prompt || ""));
}
function parseFrontmatter(content) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
if (!match) {
return { attributes: {}, body: content, hasFrontmatter: false };
}
const attributes = {};
for (const rawLine of match[1].split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const colonIndex = line.indexOf(":");
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim();
const value = stripQuotes(line.slice(colonIndex + 1).trim());
if (key) attributes[key] = value;
}
return {
attributes,
body: content.slice(match[0].length),
hasFrontmatter: true,
};
}
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
.map((entry) => {
if (typeof entry === "string") return entry;
const slug = typeof entry?.slug === "string" ? entry.slug : "";
return slug;
})
.filter(Boolean)
.map((slug) => `/${slug}`);
if (values.length <= maxItems) {
return values.join(", ");
}
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
}
function getUserSkillsDir(electronApp) {
const userDataDir = electronApp?.getPath?.("userData");
if (!userDataDir) {
throw new Error("Electron app userData path is unavailable.");
}
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
}
async function ensureUserSkillsDir(electronApp) {
const skillsDir = getUserSkillsDir(electronApp);
await fsPromises.mkdir(skillsDir, { recursive: true });
return skillsDir;
}
async function ensureUserSkillsReadme(electronApp) {
const skillsDir = await ensureUserSkillsDir(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir);
if (dirEntries.length === 0) {
await fsPromises.writeFile(
path.join(skillsDir, USER_SKILLS_README_NAME),
USER_SKILLS_README_CONTENT,
"utf8",
);
}
return skillsDir;
}
async function scanUserSkills(electronApp) {
const skillsDir = await ensureUserSkillsReadme(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
const skills = [];
const warnings = [];
for (const entry of dirEntries) {
// Only process actual directories, skipping symlinks for security
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const dirName = entry.name;
// Basic path traversal protection: skip any directory name containing path separators
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
continue;
}
const skillDir = path.join(skillsDir, dirName);
const skillPath = path.join(skillDir, "SKILL.md");
const baseItem = {
id: dirName,
slug: slugifySkill(dirName),
directoryName: dirName,
directoryPath: skillDir,
skillPath,
name: dirName,
description: "",
status: "warning",
warnings: [],
};
try {
await fsPromises.access(skillPath);
} catch {
baseItem.warnings.push("Missing SKILL.md");
warnings.push(`${dirName}: Missing SKILL.md`);
skills.push(baseItem);
continue;
}
try {
const stat = await fsPromises.lstat(skillPath);
if (stat.isSymbolicLink()) {
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
skills.push(baseItem);
continue;
}
if (!stat.isFile()) {
baseItem.warnings.push("SKILL.md must be a regular file.");
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
skills.push(baseItem);
continue;
}
if (stat.size > MAX_SKILL_BYTES) {
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
warnings.push(`${dirName}: SKILL.md is too large.`);
skills.push(baseItem);
continue;
}
const content = await fsPromises.readFile(skillPath, "utf8");
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
const name = stripQuotes(attributes.name || "").trim();
const description = stripQuotes(attributes.description || "").trim();
const usableSlug = slugifySkill(name || dirName);
if (!hasFrontmatter) {
baseItem.warnings.push("Missing YAML frontmatter.");
}
if (!name) {
baseItem.warnings.push("Missing frontmatter field: name.");
}
if (!description) {
baseItem.warnings.push("Missing frontmatter field: description.");
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
}
if (!usableSlug) {
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
}
if (baseItem.warnings.length > 0) {
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
skills.push({
...baseItem,
slug: usableSlug,
name: name || dirName,
description,
});
continue;
}
skills.push({
...baseItem,
slug: usableSlug,
name,
description,
status: "ready",
warnings: [],
body,
mtimeMs: stat.mtimeMs,
});
} catch (error) {
const warning = formatSkillReadWarning(error);
baseItem.warnings.push(warning);
warnings.push(`${dirName}: ${warning}`);
skills.push(baseItem);
}
}
const readySkillsBySlug = new Map();
for (const skill of skills) {
if (skill.status !== "ready" || !skill.slug) continue;
const matches = readySkillsBySlug.get(skill.slug);
if (matches) {
matches.push(skill);
} else {
readySkillsBySlug.set(skill.slug, [skill]);
}
}
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
if (duplicateSkills.length < 2) continue;
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
for (const skill of duplicateSkills) {
skill.status = "warning";
skill.warnings = [...skill.warnings, duplicateWarning];
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
}
}
const readyCount = skills.filter((skill) => skill.status === "ready").length;
const warningCount = skills.filter((skill) => skill.status === "warning").length;
return {
directoryPath: skillsDir,
readyCount,
warningCount,
skills: skills.map((skill) => ({
id: skill.id,
slug: skill.slug,
directoryName: skill.directoryName,
directoryPath: skill.directoryPath,
skillPath: skill.skillPath,
name: skill.name,
description: skill.description,
status: skill.status,
warnings: skill.warnings,
})),
warnings,
_readySkills: skills.filter((skill) => skill.status === "ready"),
};
}
/**
* Scores how well a skill matches a user prompt.
*
* Scored based on:
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
* - 1 point per keyword overlap (after tokenization/stopword filtering)
*
* @param {string} prompt - The user prompt
* @param {object} skill - The skill object from scanUserSkills
* @returns {number} The score (higher is better)
*/
function scoreSkillMatch(prompt, skill) {
const name = String(skill.name || "").trim();
const directoryName = String(skill.directoryName || "").trim();
// High weight for an exact plain-text mention of the skill name.
if (
(name && containsPlaintextPhrase(prompt, name)) ||
(directoryName && containsPlaintextPhrase(prompt, directoryName))
) {
return 50;
}
// Fallback to token keyword overlap
const promptTokens = new Set(tokenize(prompt));
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
let overlap = 0;
for (const token of skillTokens) {
if (promptTokens.has(token)) overlap += 1;
}
return overlap;
}
/**
* Builds the contextual prompt part from matched user skills.
*
* @param {object} electronApp - The Electron app instance
* @param {string} prompt - The user's input prompt
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
*/
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
const status = await scanUserSkills(electronApp);
const readySkills = status._readySkills || [];
const trimmedPrompt = String(prompt || "").trim();
if (readySkills.length === 0) {
return { context: "", status };
}
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
const indexLine = indexSkills
.map((skill) => `${skill.name}: ${skill.description}`)
.join("; ");
const orderedExplicitSlugs = [];
const seenExplicitSlugs = new Set();
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
const slug = slugifySkill(rawSlug);
if (!slug || seenExplicitSlugs.has(slug)) continue;
seenExplicitSlugs.add(slug);
orderedExplicitSlugs.push(slug);
}
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
const explicitSlugSet = new Set(cappedExplicitSlugs);
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
const explicitSkills = [];
const unavailableExplicitSlugs = [];
for (const slug of cappedExplicitSlugs) {
const skill = readySkillsBySlug.get(slug);
if (skill) {
explicitSkills.push(skill);
} else {
unavailableExplicitSlugs.push(slug);
}
}
const matchedSkills = readySkills
.filter((skill) => !explicitSlugSet.has(skill.slug))
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
.filter((entry) => entry.score >= 2)
.sort((left, right) => right.score - left.score)
.slice(0, MAX_MATCHED_SKILLS)
.map((entry) => entry.skill);
const finalSkills = [...explicitSkills, ...matchedSkills];
const parts = [
"User-managed skills are installed in Netcatty.",
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
"Use a user-managed skill only when it clearly matches the current request.",
];
if (additionalExplicitCount > 0) {
parts.push(
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
);
}
if (unavailableExplicitSlugs.length > 0) {
parts.push(
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
);
}
if (finalSkills.length > 0) {
const includedSkillSections = [];
const omittedSkills = [];
const truncatedSkills = [];
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
let budgetStopIndex = finalSkills.length;
for (let index = 0; index < finalSkills.length; index += 1) {
const skill = finalSkills[index];
const heading = `### ${skill.name}\n`;
const maxBodyChars = Math.min(
MAX_MATCHED_SKILL_CHARS,
Math.max(remainingSkillChars - heading.length, 0),
);
if (maxBodyChars <= 0) {
omittedSkills.push(skill);
continue;
}
const rawBody = String(skill.body || "").trim();
if (!rawBody) {
omittedSkills.push(skill);
continue;
}
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
omittedSkills.push(skill);
budgetStopIndex = index;
continue;
}
const body = rawBody.slice(0, maxBodyChars);
if (!body) {
omittedSkills.push(skill);
continue;
}
includedSkillSections.push(`${heading}${body}`);
remainingSkillChars -= heading.length + body.length;
if (body.length < rawBody.length) {
truncatedSkills.push(skill);
budgetStopIndex = index + 1;
break;
}
}
parts.push("Matched user-managed skills for this request:");
if (includedSkillSections.length > 0) {
parts.push(...includedSkillSections);
}
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
for (const skill of omittedAfterIncluded) {
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
omittedSkills.push(skill);
}
}
if (truncatedSkills.length > 0) {
parts.push(
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
);
}
if (omittedSkills.length > 0) {
parts.push(
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
);
}
}
return {
context: parts.join("\n\n"),
status,
};
}
function toPublicUserSkillsStatus(status) {
if (!status || typeof status !== "object") {
return status;
}
const publicStatus = { ...status };
delete publicStatus._readySkills;
return publicStatus;
}
module.exports = {
USER_SKILLS_DIR_NAME,
getUserSkillsDir,
ensureUserSkillsDir,
ensureUserSkillsReadme,
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
};

View File

@@ -0,0 +1,336 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { buildUserSkillsContext, scanUserSkills } = require("./userSkills.cjs");
async function withUserSkills(skillDefinitions, run) {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-user-skills-"));
const userDataDir = path.join(rootDir, "userData");
const skillsDir = path.join(userDataDir, "Skills");
await fs.mkdir(skillsDir, { recursive: true });
for (const skill of skillDefinitions) {
const skillDir = path.join(skillsDir, skill.directoryName);
await fs.mkdir(skillDir, { recursive: true });
const content = [
"---",
`name: ${skill.name}`,
`description: ${skill.description}`,
"---",
"",
skill.body,
"",
].join("\n");
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf8");
}
const electronApp = {
getPath(key) {
return key === "userData" ? userDataDir : "";
},
};
try {
await run(electronApp);
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
}
test("does not auto-match a user skill from an absolute path segment", async () => {
await withUserSkills(
[
{
directoryName: "Tmp Helper",
name: "tmp",
description: "Helper for scratch space workflows.",
body: "Body for tmp",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"please inspect /tmp/netcatty.log",
[],
);
assert.equal(result.context.includes("Matched user-managed skills for this request:"), false);
assert.equal(result.context.includes("Body for tmp"), false);
},
);
});
test("keeps every explicitly selected skill in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Alpha One",
name: "Alpha One",
description: "Alpha helper.",
body: "Body for Alpha One",
},
{
directoryName: "Beta Two",
name: "Beta Two",
description: "Beta helper.",
body: "Body for Beta Two",
},
{
directoryName: "Gamma Three",
name: "Gamma Three",
description: "Gamma helper.",
body: "Body for Gamma Three",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["alpha-one", "beta-two", "gamma-three"],
);
assert.equal(result.context.includes("Body for Alpha One"), true);
assert.equal(result.context.includes("Body for Beta Two"), true);
assert.equal(result.context.includes("Body for Gamma Three"), true);
},
);
});
test("preserves an unavailable explicit selection in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Beta",
name: "Beta",
description: "Beta helper.",
body: "Body for Beta",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["missing-skill"],
);
assert.equal(result.context.includes("Available user skills: Beta: Beta helper."), true);
assert.equal(result.context.includes("/missing-skill"), true);
assert.match(result.context, /explicitly selected/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("initializing an empty skills directory creates only an instructions file", async () => {
await withUserSkills([], async (electronApp) => {
const status = await scanUserSkills(electronApp);
const entries = await fs.readdir(status.directoryPath);
assert.deepEqual(status.skills, []);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 0);
assert.deepEqual(entries.sort(), ["README.txt"]);
});
});
test("unreadable SKILL.md becomes a warning instead of aborting the entire scan", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
{
directoryName: "Broken Skill",
name: "Broken Skill",
description: "This file will be unreadable.",
body: "Broken body",
},
],
async (electronApp) => {
const unreadablePath = path.join(
electronApp.getPath("userData"),
"Skills",
"Broken Skill",
"SKILL.md",
);
await fs.chmod(unreadablePath, 0o000);
try {
const status = await scanUserSkills(electronApp);
const workingSkill = status.skills.find((skill) => skill.name === "Working Skill");
const brokenSkill = status.skills.find((skill) => skill.directoryName === "Broken Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(workingSkill?.status, "ready");
assert.equal(brokenSkill?.status, "warning");
assert.match(brokenSkill?.warnings?.[0] || "", /Failed to read SKILL\.md/i);
} finally {
await fs.chmod(unreadablePath, 0o644);
}
},
);
});
test("symlinked SKILL.md is downgraded to a warning and never injected", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
],
async (electronApp) => {
const skillsDir = path.join(electronApp.getPath("userData"), "Skills");
const linkedDir = path.join(skillsDir, "Linked Skill");
const externalTarget = path.join(skillsDir, "..", "outside-secret.md");
await fs.mkdir(linkedDir, { recursive: true });
await fs.writeFile(
externalTarget,
[
"---",
"name: Linked Skill",
"description: Linked helper.",
"---",
"",
"TOPSECRET",
"",
].join("\n"),
"utf8",
);
await fs.symlink(externalTarget, path.join(linkedDir, "SKILL.md"));
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["linked-skill"]);
const linkedSkill = status.skills.find((skill) => skill.directoryName === "Linked Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(linkedSkill?.status, "warning");
assert.match(linkedSkill?.warnings?.[0] || "", /symbolic link/i);
assert.equal(result.context.includes("TOPSECRET"), false);
assert.match(result.context, /linked-skill/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("duplicate normalized slugs are downgraded to warnings and not injected explicitly", async () => {
await withUserSkills(
[
{
directoryName: "Foo Bar",
name: "Foo Bar",
description: "First skill.",
body: "Body for Foo Bar",
},
{
directoryName: "foo-bar",
name: "foo-bar",
description: "Second skill.",
body: "Body for foo-bar",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["foo-bar"]);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 2);
assert.equal(status.skills.every((skill) => skill.status === "warning"), true);
assert.equal(
status.skills.every((skill) =>
skill.warnings.some((warning) => warning.includes('Duplicate skill slug "foo-bar"')),
),
true,
);
assert.equal(result.context.includes("Body for Foo Bar"), false);
assert.equal(result.context.includes("Body for foo-bar"), false);
},
);
});
test("skills without a usable ASCII slug are downgraded to warnings", async () => {
await withUserSkills(
[
{
directoryName: "部署助手",
name: "部署助手",
description: "Deployment helper.",
body: "Body for 部署助手",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 1);
assert.equal(status.skills[0]?.status, "warning");
assert.equal(status.skills[0]?.slug, "");
assert.match(
status.skills[0]?.warnings?.[0] || "",
/usable slug/i,
);
},
);
});
test("explicit selections are capped to stay within the prompt budget", async () => {
await withUserSkills(
[
{
directoryName: "Skill One",
name: "Skill One",
description: "Helper one.",
body: "BODY_ONE_" + "a".repeat(3500),
},
{
directoryName: "Skill Two",
name: "Skill Two",
description: "Helper two.",
body: "BODY_TWO_" + "b".repeat(3500),
},
{
directoryName: "Skill Three",
name: "Skill Three",
description: "Helper three.",
body: "BODY_THREE_" + "c".repeat(3500),
},
{
directoryName: "Skill Four",
name: "Skill Four",
description: "Helper four.",
body: "BODY_FOUR_" + "d".repeat(3500),
},
{
directoryName: "Skill Five",
name: "Skill Five",
description: "Helper five.",
body: "BODY_FIVE_" + "e".repeat(3500),
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["skill-one", "skill-two", "skill-three", "skill-four", "skill-five"],
);
assert.equal(result.context.includes("BODY_ONE_"), true);
assert.equal(result.context.includes("BODY_TWO_"), true);
assert.equal(result.context.includes("BODY_THREE_"), true);
assert.equal(result.context.includes("BODY_FOUR_"), false);
assert.equal(result.context.includes("BODY_FIVE_"), false);
assert.match(result.context, /prompt budget|additional selected/i);
},
);
});

View File

@@ -15,6 +15,11 @@ const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
const {
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
} = require("./ai/userSkills.cjs");
// ── Extracted modules ──
const {
@@ -24,6 +29,7 @@ const {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
@@ -35,6 +41,9 @@ const {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
@@ -95,7 +104,8 @@ function getSkillsCliInvocation() {
};
}
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession }) {
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession, userSkillsContext }) {
const userSkillsPreamble = userSkillsContext ? `${userSkillsContext}\n\n` : "";
if (mode === "skills") {
const { commandPrefix: cliCommandPrefix, launcherPath, usesLauncher } = getSkillsCliInvocation();
const skillHint = existsSync(NETCATTY_TOOL_SKILL_PATH)
@@ -133,6 +143,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`${skillHint}` +
`${cliHint}` +
@@ -161,6 +172,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
}
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
@@ -234,6 +246,34 @@ function resolveProviderApiKey(providerId) {
};
}
function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) {
const parts = [
typeof apiKey === "string" ? apiKey.trim() : "",
typeof provider?.id === "string" ? provider.id.trim() : "",
typeof provider?.providerId === "string" ? provider.providerId.trim() : "",
typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "",
customConfig
? [
"custom",
customConfig.providerName || "",
customConfig.baseUrl || "",
customConfig.envKey || "",
customConfig.envKeyPresent ? "1" : "0",
// authHash changes when the user rotates their hardcoded api_key
// or the env_key's resolved value; without it a cached ACP
// provider would keep serving the stale key.
customConfig.authHash || "",
].join(":")
: "",
].filter(Boolean);
if (parts.length === 0) {
return null;
}
return getCodexAuthFingerprint(parts.join("\n"));
}
/** Check if TLS verification should be skipped for a given provider. */
function shouldSkipTLSVerify(providerId) {
if (!providerId) return false;
@@ -734,6 +774,41 @@ function streamRequest(url, options, event, requestId, skipTLS) {
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
return { ok: true, ...toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
return {
ok: !openResult,
error: openResult || undefined,
...toPublicUserSkillsStatus(status),
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// ── Provider config sync (renderer → main, keys stay encrypted) ──
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
@@ -1689,8 +1764,14 @@ function registerHandlers(ipcMain) {
return { path: resolvedPath, version, available: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
// When the user clicks "Refresh Status" in Settings we also want to
// rescan the shell env — otherwise a newly-exported variable in
// .zshrc stays invisible until they restart netcatty entirely.
if (options && options.refreshShellEnv) {
invalidateShellEnvCache();
}
try {
const result = await runCodexCli(["login", "status"]);
const rawOutput = [result.stdout, result.stderr]
@@ -1724,11 +1805,33 @@ function registerHandlers(ipcMain) {
}
}
// `codex login status` only reflects ~/.codex/auth.json. A user who
// configured a custom provider directly in ~/.codex/config.toml is
// functional from the CLI but would look "not_logged_in" here. Probe
// config.toml so we can surface that as a valid ready state instead of
// pushing the user into the ChatGPT login flow.
let customConfig = null;
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
try {
const shellEnv = await getShellEnv();
customConfig = readCodexCustomProviderConfig(shellEnv);
if (customConfig) {
state = "connected_custom_config";
}
} catch {
customConfig = null;
}
}
return {
state,
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput: effectiveRawOutput,
exitCode: result.exitCode,
customConfig,
};
} catch (err) {
return {
@@ -1736,6 +1839,7 @@ function registerHandlers(ipcMain) {
isConnected: false,
rawOutput: err?.message || String(err),
exitCode: null,
customConfig: null,
};
}
});
@@ -1847,7 +1951,10 @@ function registerHandlers(ipcMain) {
return {
ok: true,
state,
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput,
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
.filter((chunk) => chunk.trim().length > 0)
@@ -2102,10 +2209,29 @@ function registerHandlers(ipcMain) {
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
// Mirror the stream handler's pre-flight: if Codex is pointed at a
// config.toml custom provider whose env_key is not exported, surface
// a targeted error instead of spawning codex-acp and letting it fail
// mid-init with an opaque message.
if (isCodexAgent && !apiKey) {
const preflight = getCodexCustomConfigPreflightError(
readCodexCustomProviderConfig(shellEnv),
);
if (preflight) {
return { ok: false, models: [], error: preflight };
}
}
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
if (apiKey) {
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// Claude agent auth is owned entirely by its CLI config/login state
// (`claude auth login`, ~/.claude settings, or ANTHROPIC_* in the user's
// shell env). netcatty's provider list must not override it.
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
@@ -2134,7 +2260,7 @@ function registerHandlers(ipcMain) {
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2182,7 +2308,7 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession }) => {
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
@@ -2245,7 +2371,28 @@ function registerHandlers(ipcMain) {
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
if (isCodexAgent && !apiKey) {
// Probe ~/.codex/config.toml first so we can tell a ChatGPT user
// (needs login validation) from a custom-provider user (must NOT be
// forced through ChatGPT validation, since their auth lives in
// config.toml / shell env, not auth.json).
const codexCustomConfig = isCodexAgent && !apiKey
? readCodexCustomProviderConfig(shellEnv)
: null;
// Fail loud: custom-provider config is set but has no usable auth
// material yet (env_key is named but not exported in the shell env,
// and no api_key is hardcoded). Don't spawn — codex-acp would fail
// mid-request with an opaque "Missing environment variable" error.
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
if (preflightError) {
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: preflightError,
});
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
}
if (isCodexAgent && !apiKey && !codexCustomConfig) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (shouldAbortStartup()) return { ok: true };
if (!validation.ok) {
@@ -2266,7 +2413,9 @@ function registerHandlers(ipcMain) {
}
}
const authFingerprint = isCodexAgent ? getCodexAuthFingerprint(apiKey) : null;
const authFingerprint = isCodexAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -2333,9 +2482,13 @@ function registerHandlers(ipcMain) {
cleanupAcpProvider(chatSessionId);
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
if (apiKey) {
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
@@ -2366,7 +2519,7 @@ function registerHandlers(ipcMain) {
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2378,7 +2531,7 @@ function registerHandlers(ipcMain) {
resolvedCommand,
resolvedArgs,
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null,
});
if (isCopilotAgent) {
@@ -2452,8 +2605,12 @@ function registerHandlers(ipcMain) {
: acpArgs || [],
env: (() => {
const fallbackEnv = withCliDiscoveryEnv(
apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
isCodexAgent && apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
);
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
@@ -2465,7 +2622,7 @@ function registerHandlers(ipcMain) {
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2513,6 +2670,7 @@ function registerHandlers(ipcMain) {
prompt,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
// Build message content: text + optional attachments

View File

@@ -1184,8 +1184,8 @@ const api = {
aiResolveCli: async (params) => {
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
},
aiCodexGetIntegration: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
aiCodexGetIntegration: async (options) => {
return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options);
},
aiCodexStartLogin: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
@@ -1230,6 +1230,15 @@ const api = {
aiMcpSetToolIntegrationMode: async (mode) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-tool-integration-mode", { mode });
},
aiUserSkillsGetStatus: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:status");
},
aiUserSkillsOpenFolder: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:open");
},
aiUserSkillsBuildContext: async (prompt, selectedSkillSlugs) => {
return ipcRenderer.invoke("netcatty:ai:user-skills:build-context", { prompt, selectedSkillSlugs });
},
// MCP approval gate: renderer receives approval requests from main process
onMcpApprovalRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
@@ -1246,8 +1255,8 @@ const api = {
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession });
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });

72
global.d.ts vendored
View File

@@ -6,16 +6,13 @@ declare module "*.cjs" {
export = value;
}
declare global {
// Extend HTMLInputElement to support webkitdirectory attribute
namespace JSX {
interface IntrinsicElements {
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
webkitdirectory?: string;
}, HTMLInputElement>;
}
declare module 'react' {
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
webkitdirectory?: string | boolean;
}
}
declare global {
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
@@ -732,11 +729,21 @@ declare global {
acpCommand?: string;
acpArgs?: string[];
}>>;
aiCodexGetIntegration?(): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
} | null;
}>;
aiCodexStartLogin?(): Promise<{
ok: boolean;
@@ -795,11 +802,54 @@ declare global {
connected: boolean;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
aiMcpSetToolIntegrationMode?(mode: 'mcp' | 'skills'): Promise<{ ok: boolean; error?: string }>;
aiUserSkillsGetStatus?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsOpenFolder?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsBuildContext?(prompt: string, selectedSkillSlugs?: string[]): Promise<{
ok: boolean;
context?: string;
error?: string;
}>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;

View File

@@ -48,6 +48,7 @@ interface AcpBridge {
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
@@ -87,6 +88,7 @@ export async function runAcpAgentTurn(
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
@@ -161,6 +163,7 @@ export async function runAcpAgentTurn(
images?.length ? images : undefined,
toolIntegrationMode,
defaultTargetSession,
userSkillsContext,
).then((result) => {
if (result?.ok === false) {
settle(() => {

View File

@@ -14,10 +14,11 @@ export interface SystemPromptContext {
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
webSearchEnabled?: boolean;
userSkillsContext?: string;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
@@ -59,7 +60,8 @@ ${permissionRules}
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
}
function buildScopeDescription(

View File

@@ -67,3 +67,4 @@ export function getManagedAgentStoredPath(
);
return fallbackAgent?.command ?? null;
}

View File

@@ -112,6 +112,10 @@ export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
// Vault: Show Recently Connected hosts section
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only_ungrouped_hosts_in_root_v1';
// Top tabs: Show standalone SFTP view tab
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
// Group Configurations (default settings inherited by hosts)
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';

38
package-lock.json generated
View File

@@ -1154,7 +1154,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1800,6 +1799,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1821,6 +1821,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1837,6 +1838,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1851,6 +1853,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3306,7 +3309,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6295,7 +6297,6 @@
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -6376,7 +6377,6 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -6406,7 +6406,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6936,7 +6935,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6987,7 +6985,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7548,7 +7545,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8291,7 +8287,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8575,7 +8572,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8957,6 +8953,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8977,6 +8974,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9206,7 +9204,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10555,7 +10552,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12045,7 +12041,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -12663,8 +12658,7 @@
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -12919,6 +12913,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12931,7 +12926,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -13691,6 +13685,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13708,6 +13703,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13898,7 +13894,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13908,7 +13903,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -15229,6 +15223,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15293,6 +15288,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -15367,7 +15363,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15560,7 +15555,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15581,7 +15575,6 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -15920,7 +15913,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16014,7 +16006,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16293,7 +16284,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}