Compare commits

...

33 Commits

Author SHA1 Message Date
陈大猫
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
28 changed files with 850 additions and 173 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,7 @@ 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.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',

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,7 @@ const zhCN: Messages = {
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
// AI Claude Code
'ai.claude.title': 'Claude Code',

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';
@@ -480,6 +481,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
const isCodexManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : 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;
@@ -520,10 +557,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, 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(() => {

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

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

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

@@ -23,6 +23,9 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
@@ -166,10 +169,26 @@ 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');
@@ -375,7 +394,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 +420,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 +445,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 && (

View File

@@ -30,7 +30,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
@@ -556,16 +556,13 @@ export function useAIChatStreaming({
// avoiding plaintext key transit across the IPC boundary.
// Resolve the correct provider based on agent type:
// - Claude agent → anthropic provider (prefer over generic custom)
// - Codex agent → openai provider
// - Codex agent → openai provider (fallback to openai-compatible custom)
const agentProviderId = (() => {
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
return (
context.providers.find(p => p.providerId === 'anthropic' && p.enabled && p.apiKey)?.id
?? context.providers.find(p => p.providerId === 'custom' && p.enabled && p.apiKey && p.baseURL)?.id
);
return findManagedAgentProvider(context.providers, 'claude')?.id;
}
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
return context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey)?.id;
return findManagedAgentProvider(context.providers, 'codex')?.id;
}
return undefined;
})();

View File

@@ -18,6 +18,7 @@ import type {
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
findManagedAgentProvider,
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
@@ -304,18 +305,16 @@ 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 hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex"));
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));
@@ -524,9 +523,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
hasCompatibleProvider={hasCodexCompatibleProvider}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}

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,7 @@ export const CodexConnectionCard: React.FC<{
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasOpenAiProviderKey: boolean;
hasCompatibleProvider: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
@@ -31,7 +31,7 @@ export const CodexConnectionCard: React.FC<{
integration,
loginSession,
isLoading,
hasOpenAiProviderKey,
hasCompatibleProvider,
error,
onRefresh,
onConnect,
@@ -42,6 +42,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 +60,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 +74,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 +153,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,7 +174,26 @@ export const CodexConnectionCard: React.FC<{
</Button>
</div>
{hasOpenAiProviderKey && (
{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>
)}
</>
)}
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</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";
@@ -57,7 +70,7 @@ 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 }>;

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

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

@@ -24,6 +24,7 @@ const {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
@@ -35,6 +36,9 @@ const {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
@@ -234,6 +238,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;
@@ -1689,8 +1721,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 +1762,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 +1796,7 @@ function registerHandlers(ipcMain) {
isConnected: false,
rawOutput: err?.message || String(err),
exitCode: null,
customConfig: null,
};
}
});
@@ -1847,7 +1908,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,6 +2166,19 @@ 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 (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
@@ -2143,7 +2220,7 @@ function registerHandlers(ipcMain) {
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2254,7 +2331,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) {
@@ -2275,11 +2373,9 @@ function registerHandlers(ipcMain) {
}
}
const authFingerprint = isCodexAgent
? getCodexAuthFingerprint(apiKey)
: isClaudeAgent
? getCodexAuthFingerprint(apiKey + (resolvedProvider?.provider?.baseURL || ""))
: null;
const authFingerprint = isCodexAgent || isClaudeAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -2388,7 +2484,7 @@ function registerHandlers(ipcMain) {
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2400,7 +2496,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) {
@@ -2496,7 +2592,7 @@ function registerHandlers(ipcMain) {
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),

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

14
global.d.ts vendored
View File

@@ -732,11 +732,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;

View File

@@ -1,4 +1,4 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
import type { DiscoveredAgent, ExternalAgentConfig, ProviderConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
@@ -67,3 +67,24 @@ export function getManagedAgentStoredPath(
);
return fallbackAgent?.command ?? null;
}
export function findManagedAgentProvider(
providers: ProviderConfig[],
agentKey: ManagedAgentKey,
): ProviderConfig | undefined {
if (agentKey === 'codex') {
return (
providers.find((provider) => provider.providerId === 'openai' && provider.enabled && !!provider.apiKey)
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
);
}
if (agentKey === 'claude') {
return (
providers.find((provider) => provider.providerId === 'anthropic' && provider.enabled && !!provider.apiKey)
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
);
}
return undefined;
}

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';