Compare commits

..
Author SHA1 Message Date
bincxz 9c63b40ddf test(terminal): stabilize split menu coverage 2026-08-20 15:52:52 +08:00
bincxz 490554d6a6 fix(terminal): align split menu icons 2026-08-20 15:49:18 +08:00
bincxz f7a8e1557b fix(terminal): align split menu shortcuts 2026-08-20 15:38:15 +08:00
陈大猫 bd8f7ee7a6 Merge pull request #3080 from binaricat/feat/ai-sidebar-provider-model-thinking
feat(ai): unify sidebar provider, model, and thinking pickers
2026-08-20 12:19:45 +08:00
bincxz 71f6f51707 Merge remote-tracking branch 'origin/main' into feat/ai-sidebar-provider-model-thinking
# Conflicts:
#	components/settings/tabs/ai/ProviderConfigForm.tsx
2026-08-20 11:53:43 +08:00
bincxz 0288d1610a fix(ai): skip no-op AI config updates and simplify permission picker
Avoid rewriting thinking/provider maps when values are unchanged so
mounted AI panels do not re-render. Put permission title and short
description on one line and drop the Cursor CLI parenthetical.
2026-08-20 11:52:29 +08:00
bincxz b701df8822 fix(ai): align Catty thinking levels with vendor capabilities
Stop advertising Gemini 3.7 Flash minimal, GPT-5.x-chat reasoning, and
Claude 4.7+ extended thinking. Map GPT-5 Off to minimal, send adaptive
effort on Claude 4.6+, and keep composer Enter on an exact or custom id.
2026-08-20 11:05:12 +08:00
陈大猫 10d7ff9879 Merge pull request #3084 from DeanCen/fix/3083-ai-request-content-length
fix: send Content-Length for AI request bodies instead of chunked encoding
2026-08-20 10:05:58 +08:00
DeanCen a43ddb6804 fix: send Content-Length for AI request bodies instead of chunked encoding
Node's http.request falls back to Transfer-Encoding: chunked when no
Content-Length header is present. Both AI request paths pass the AI SDK's
headers through unchanged, and those headers carry no content-length (in the
browser fetch computes it), so every AI request body went out chunked.

The body is a fully-buffered string at that point, so its length is known.
Send an explicit Content-Length instead, measured with Buffer.byteLength so
multi-byte system prompts are not truncated.

Chunked request bodies are the less interoperable choice: on the plain-HTTP
path an HTTP proxy has to de-frame and re-serialize them, and some fail on
larger bodies. Xray's HTTP inbound drops the connection once the chunked body
exceeds roughly 8KB. Because Catty's system prompt plus tool definitions is
about 48KB, chat against an http:// provider behind a system HTTP proxy failed
with an opaque "socket hang up" while the connection test kept passing, since
that test issues GET /v1/models with no body and never exercises this path.

Adds three regression tests covering the streaming path, byte-accurate length
for multi-byte bodies, and the non-streaming path.

Closes #3083
2026-08-20 00:30:55 +08:00
bincxz 97ef84db06 fix(ai): persist discovered context windows from composer picks
Codex P2: switching to a catalog model dropped context_length, so
Catty budgeted 128k instead of the advertised window. Keep the
metadata on picker models and write it to modelContextWindows.
2026-08-19 23:51:05 +08:00
bincxz 8bce8c15a0 fix(ai): do not display unset thinking as Off
Codex P2: models whose menu includes Off still showed Off for users
with no stored preference, while the request sent no override. Leave
the chip unselected until the user picks a level.
2026-08-19 23:40:09 +08:00
bincxz b7b35afa24 fix(ai): persist explicit Off and omit unset thinking options
Codex P2: missing agentThinkingMap was treated as Off, so GPT-5.1+
got reasoningEffort=none and Gemini 2.5 Flash got thinkingBudget=0
without a user choice. Store Off as an explicit value and leave an
absent preference unset so the provider default is used.
2026-08-19 23:34:28 +08:00
bincxz 958972ffa0 fix(ai): omit Gemini 3 Flash thinking when preference is unset
Codex P2: an empty agentThinkingMap was normalized to Off and then to
minimal, so every Gemini 3 Flash turn overrode the provider default.
Only send thinkingLevel when the user actually picked a level.
2026-08-19 23:29:50 +08:00
bincxz d137662d02 fix(ai): treat gpt-5-chat models as non-reasoning
Codex P2: gpt-5-chat-latest matched the gpt-5 heuristic and received
reasoningEffort, which the OpenAI chat-only variants reject. Exclude
gpt-5-chat* from the thinking chip and request options.
2026-08-19 23:24:35 +08:00
bincxz 10ca915370 fix(ai): include Catty thinking map in settings sync
Codex P2: agentThinkingMap was persisted locally but omitted from
collect/apply sync, so backups and other devices restored provider
and model without the chosen reasoning level.
2026-08-19 23:18:50 +08:00
bincxz 33dc249d74 fix(ai): do not Enter-select custom IDs on external agents
Codex P2: hiding the custom-model row still left Enter falling back
to the typed query, which send-time validation then discarded. Only
use the query as a custom ID when Catty offers that action.
2026-08-19 23:10:34 +08:00
bincxz b490eb3273 fix(ai): keep unset Catty thinking as provider default
Codex P2: opening the composer on Gemini Pro persisted Low for users
who had never chosen a thinking level. Only remap an explicit stale
value; leave an empty preference unset so the request uses the
provider default.
2026-08-19 23:03:56 +08:00
bincxz 55852918c2 fix(ai): sync composer model prefs across retained panels
Codex P2: pinning or recents in one parked ChatInput only updated that
instance. Subscribe every mounted composer so sibling terminals see
the same recent/pinned list without remounting.
2026-08-19 22:57:21 +08:00
bincxz cc85cc7ebe fix(ai): keep Gemini 3.1 Pro medium and drop 3.0 Pro medium
Codex P2: gemini-3.1-pro-preview supports Medium, but Catty rewrote it
to High. Original Gemini 3 Pro still only has Low/High, so omit Medium
instead of silently converting it.
2026-08-19 22:50:29 +08:00
bincxz 6e99c9c1dc fix(ai): drop stale external-agent pinned models
Codex P2: refreshed CLI catalogs still showed saved IDs that the
catalog no longer contains, then send-time validation swapped them.
Only Catty may keep a custom ID that is not in the live list.
2026-08-19 22:45:11 +08:00
bincxz 1dac363d84 fix(ai): discover Catty models from preset Base URL when omitted
Codex P2: a built-in provider with no stored baseURL never listed
live models even though chat still used the preset host. Fall back to
PROVIDER_PRESETS.defaultBaseURL before probing /models.
2026-08-19 22:38:33 +08:00
bincxz 4eddbfab22 fix(ai): hide CLI custom models and count thinking budget once
Codex P2: external-agent pickers advertised a custom ID that send-time
validation ignored. Offer Use custom model only for Catty providers.

Codex P2: the thinking budget was added to both compaction max output
and reservedTokens, so it was subtracted twice. Keep it only on
compaction max output.
2026-08-19 22:29:33 +08:00
bincxz 54d92fb326 fix(ai): reserve Anthropic thinking budget in Catty compaction
Codex P2: medium/high thinking adds 10k/20k to the SDK max_tokens,
but pre-turn/step compaction still reserved only the 4,096 default.
Fold the selected thinking budget into output reserve and compaction
max output without changing the streamed maxTokens field.
2026-08-19 22:24:05 +08:00
bincxz 73a9669bf7 fix(ai): hide Anthropic thinking on models without extended thinking
Codex P2: Claude 3 Haiku still offered Low/Medium/High and would send
an enabled thinking budget. Gate the chip and the provider option on
Claude 3.7 / 4+ model ids. Also memoize Catty thinking levels.
2026-08-19 22:15:34 +08:00
bincxz 5971e03ffe fix(ai): drop stale thinking levels and hide Gemini Pro Off
Codex P2: switching away from Gemini 3 Flash left `minimal` selected
on models that do not advertise it. Persist a valid level for the new
model.

Codex P2: Gemini 3 Pro and 2.5 Pro cannot disable thinking, so Off
was a lie. Those menus only offer low/medium/high.
2026-08-19 22:10:09 +08:00
bincxz 3123c33914 fix(ai): bust model cache on key change and label Gemini minimal
Codex P2: catalog cache ignored API key rotation, so a replaced
credential could keep the previous account's models. Fingerprint the
stored secret in the cache key.

Codex P2: Gemini 3 Flash cannot turn thinking off; Off was sending
thinkingLevel=minimal. Surface Minimal on the chip instead of Off.
2026-08-19 22:02:03 +08:00
bincxz 90e3f121ee fix(ai): send none only on GPT-5.1+ and honor Cursor effort params
Codex P1: o3/o4-mini/gpt-5 reject reasoning_effort=none. Only GPT-5.1+
get that Off mapping; older reasoning models omit the field.

Codex P2: Cursor ModelListItem.parameters.effort.values is the catalog
capability list. Use it when present instead of fabricating fallbacks
or dropping the thinking chip on unknown models.
2026-08-19 21:53:24 +08:00
bincxz be63983693 fix(ai): hide Catty thinking chip on non-reasoning models
Codex P2: OpenAI-style models such as gpt-4o rejected reasoning_effort
when the chip always offered Off/Low/Medium/High. Gate the chip and
the provider option on known reasoning model ids, and send explicit
none when Off is selected on a reasoning model.
2026-08-19 21:45:15 +08:00
bincxz ad16f19268 fix(ai): tighten picker chrome and align provider action buttons
Remove the provider divider and search-field outline in the sidebar
model picker. Size Save/Test/Cancel to the same h-8 box without
changing the global Button styles.
2026-08-19 21:32:18 +08:00
bincxz 77cb55315a fix(ai): keep Ollama Cloud API keys and append /v1
The Ollama preset always rewrote OpenAI-compat keys to the literal
"ollama" dummy. That is correct for local servers with no key, but it
replaced the IPC placeholder used for Cloud, so chat sent
Authorization: Bearer ollama and 401ed. Only fill the dummy when no key
is configured, and normalize a bare ollama.com origin to /v1.
2026-08-19 20:45:58 +08:00
bincxz 54647b2f9a fix(ai): clamp Gemini 3 Pro levels and union Cursor fallbacks
Omit Gemini 3 Pro Off instead of sending unsupported minimal, clamp
medium to high, and merge advertised Cursor efforts with the fallback
table so CLI-listed gpt-5 still gets a thinking chip.
2026-08-19 18:34:26 +08:00
bincxz f3d5e1e88f fix(ai): omit 2.5 Pro Off budget and keep CodeBuddy picker unsuffixed
Gemini 2.5 Pro rejects thinkingBudget 0, so Off omits the config.
Picking a CodeBuddy model without a chip level no longer writes /medium.
2026-08-19 18:25:50 +08:00
bincxz 2f96b86fed fix(ai): keep CodeBuddy settings effort and Gemini 2.5 budgets
Do not rewrite bare CodeBuddy model ids to /medium, fail closed when
Google has no model id, and send Gemini 2.5 thinkingBudget instead of
Gemini 3 thinkingLevel.
2026-08-19 18:11:41 +08:00
bincxz b29f74b925 feat(ai): wire Cursor, CodeBuddy, and Gemini thinking chips
Map Cursor effort variants and CodeBuddy SDK effort onto the shared
sidebar thinking chip, and send Gemini thinkingConfig for Catty
models that look like they support it.
2026-08-19 17:50:16 +08:00
bincxz 3d154ca235 fix(ai): switch providers from a one-line submenu
Keep the composer model menu as a single-column list. Provider
switching now drills in from the header row instead of a left rail.
2026-08-19 17:35:44 +08:00
bincxz 4acf254f72 feat(ai): unify sidebar provider, model, and thinking pickers
Give Catty a two-column provider/model menu in the composer, add search
plus recent/pinned models, and move thinking effort onto its own chip
so Claude, Codex, Grok, and Catty share the same compact controls.

Closes #2137
Closes #2637
Closes #1884
2026-08-19 17:23:06 +08:00
陈大猫 69af65fd02 Merge pull request #3079 from binaricat/feat/3078-openai-responses-api
feat(ai): add optional OpenAI Responses API format
2026-08-19 16:22:18 +08:00
bincxz fdecf652bf feat(ai): add optional OpenAI Responses API format
Closes #3078

OpenAI-compatible providers always used Chat Completions. Some
relays cache poorly on that path. Add a per-provider Responses
opt-in so Catty can call /v1/responses while keeping Chat
Completions as the default.
2026-08-19 16:10:20 +08:00
陈大猫 76f3da2396 Merge pull request #3075 from binaricat/feat/3073-sftp-owner-column
feat(sftp): show file owner column in the SFTP list
2026-08-19 15:16:30 +08:00
bincxz 31d3f76c32 fix(sftp): keep owner when normalizing tree directory listings
Codex review: expanded tree rows rebuild RemoteFile entries without
copying owner, so child panes showed "--" even when the bridge had it.
2026-08-19 15:10:45 +08:00
bincxz 0c4360808a fix(sftp): use symlink entry owner and ACL longname markers
Codex review: read the local directory entry owner via lstat for
symlinks, and accept ls-style +/@/. mode suffixes when parsing
longname usernames.
2026-08-19 14:50:41 +08:00
陈大猫 2a0b0cdeb0 Merge pull request #3070 from binaricat/fix/encrypted-putty-ed25519-ppk
fix(ssh): decrypt encrypted PuTTY ed25519 PPK keys
2026-08-19 14:46:06 +08:00
陈大猫 c98eb84637 Merge pull request #3068 from binaricat/fix/3064-windows-tray-panel-size
fix(tray): place Windows tray panel at designed size next to the icon
2026-08-19 14:45:22 +08:00
bincxz 7b8c944510 feat(sftp): show owner column in the file list
Surface the remote file owner in the SFTP list and tree, using the
existing toggleable column menu. Parse the username from SFTP longname
or ls output, then fall back to uid when a name is unavailable.

Closes #3073
2026-08-19 14:39:17 +08:00
bincxz 3b7c3c0e82 fix(ssh): validate PPK Argon2 limits before runtime check
Malformed work factors must fail with the limits error even when
crypto.argon2Sync is missing, so CI on Node 22 still rejects crafted
headers instead of reporting an unsupported runtime.
2026-08-19 14:32:47 +08:00
bincxz 5ef583ed59 fix(ssh): keep converted PPK unlocked and harden v3 fixtures
Check encryption on the prepared key so SFTP/jump hops do not prompt
again after a PPK is rewritten to OpenSSH. Build Argon2-limit tests
from stub headers so they do not need crypto.argon2Sync. Keep the
empty HMAC key for unencrypted PPK v3, matching PuTTYgen.
2026-08-19 14:28:06 +08:00
bincxz d8c4e6934f fix(tray): drop CSS shadow that double-composites on macOS
The overlay window already uses native hasShadow. CSS shadow-lg on the
transparent rounded card paints a second halo, which macOS then
composites as an extra outline under the panel.
2026-08-19 14:23:41 +08:00
陈大猫 a3e8ab8d71 Merge pull request #3074 from binaricat/fix/3072-never-auto-lock-tray
fix(app-lock): skip tray hide lock when timeout is never
2026-08-19 14:18:45 +08:00
bincxz a08b5fbcd0 fix(ssh): cap PPK v3 Argon2 work factors
Reject non-positive or unbounded memory/passes/parallelism (and
malformed salt) before argon2Sync so a crafted PPK cannot stall the
Electron main process.
2026-08-19 14:18:28 +08:00
bincxz 2c009b3ba4 fix(app-lock): skip tray hide lock when timeout is never
Closes #3072

Close-to-tray and app-hide always requested a background lock whenever
app lock was enabled. "Never lock automatically" only disabled the idle
timer, so clicking X on Windows still asked for the password on reopen.

Honor timeoutMinutes === 0 for background locks. Startup and manual
locks still apply. Setting copy now says Never skips both.
2026-08-19 14:14:13 +08:00
bincxz fee3662e82 fix(tray): trust event bounds over cursor for keyboard tray activation
Codex P2: a keyboard or accessibility activation can fire with valid
event bounds while the pointer sits on another monitor. Keep those
bounds for display and placement; only reject stale tray.getBounds()
when it disagrees with the cursor.
2026-08-19 14:13:07 +08:00
陈大猫 adf0344f73 Merge pull request #3067 from binaricat/fix/3065-lock-system-context-menu
fix(app-lock): hide native window menu while locked
2026-08-19 14:03:45 +08:00
陈大猫 4d98289f96 Merge pull request #3071 from binaricat/fix/3069-settings-search-app-lock
fix(settings): include App Lock in settings search
2026-08-19 14:02:46 +08:00
bincxz 4bf569a727 fix(settings): include App Lock in settings search
Closes #3069

App Lock lives on the System tab but was never added to the
settings search catalog, so queries such as "应用锁" and
"App Lock" returned no matches. Index the section and wire a
jump anchor so search can open it.
2026-08-19 13:59:28 +08:00
bincxz 05bed3c72c fix(ssh): decrypt encrypted PuTTY ed25519 PPK keys
ssh2 only parses PPK v2 RSA/DSA, so an encrypted PuTTYgen ed25519
key failed even with the correct passphrase. Convert PPK v2/v3
(Ed25519, ECDSA, RSA) to OpenSSH PEM before handing the key to ssh2.
2026-08-19 13:58:00 +08:00
bincxz 32ede64554 fix(tray): place Windows tray panel at designed size next to the icon
Windows right-click ignored the event bounds Electron already provides
and reused tray.getBounds() / win.getBounds(), which often report y=0
or a blown-up size. Always set 360x520 and anchor above a bottom
taskbar (or below a top one), falling back to the cursor when bounds
are stale.

Closes #3064
2026-08-19 13:52:56 +08:00
bincxz 8b9685e680 fix(app-lock): hide native window menu while locked
Windows shows the system title-bar menu on right-click in drag regions.
The lock overlay is a drag region so the window stays movable, which also
exposed Minimize/Maximize/Close. Suppress BrowserWindow system-context-menu
while locked so the lock screen cannot quit the app.

Closes #3065
2026-08-19 13:36:33 +08:00
陈大猫 1a55378b44 Merge pull request #3060 from binaricat/automation/nix-release-v1.1.81
Update Nix release metadata for v1.1.81
2026-08-19 10:53:37 +08:00
github-actions[bot] b36dbc10ec Update Nix release metadata for v1.1.81 2026-08-19 02:48:08 +00:00
陈大猫 81dce5a3e7 Merge pull request #3059 from binaricat/fix/app-lock-window-drag 2026-08-19 10:24:15 +08:00
bincxz 4e794d4af1 fix(app-lock): keep window drag region on lock overlay
The lock screen is a full-window overlay, so it covers the title-bar
drag region and is the only chrome on a locked first paint. Mark the
backdrop as app-drag and keep unlock controls app-no-drag.

Closes #3058
2026-08-19 10:18:28 +08:00
陈大猫 1205ef37ae Merge pull request #3056 from binaricat/codex/fix-grok-reasoning-effort
fix(ai): support Grok Build reasoning effort
2026-08-19 09:39:48 +08:00
陈大猫 f7d4cc14c2 Merge pull request #3055 from binaricat/codex/fix-sftp-bookmark-popover-close
fix(sftp): close bookmark popover after path selection
2026-08-19 09:39:23 +08:00
bincxz d36d4f86b3 fix(ai): preserve legacy Grok model selections 2026-08-19 00:34:19 +08:00
bincxz 757fbcfd83 fix(ai): default Grok reasoning selections 2026-08-19 00:29:07 +08:00
bincxz e0fdf48395 fix(ai): preserve Grok legacy effort choices 2026-08-19 00:20:57 +08:00
bincxz 63f44c4ac9 fix(ai): harden Grok model discovery 2026-08-19 00:15:12 +08:00
bincxz 64ba59db10 fix(ai): add Grok reasoning effort selection 2026-08-19 00:02:09 +08:00
bincxz 1617ef8956 fix(sftp): close bookmarks after path selection 2026-08-18 23:49:17 +08:00
陈大猫 8ee1cda924 Merge pull request #1609 from Ryanisgood/feature/app-lock-screen
Feat: 添加应用锁屏功能
2026-08-18 20:57:42 +08:00
bincxz ea91418f4e fix(app-lock): wait for locked startup readiness 2026-08-18 20:48:21 +08:00
bincxz e6c46f8198 fix(app-lock): defer protected actions until unlock 2026-08-18 20:45:29 +08:00
bincxz 8eaab92d31 feat(app-lock): prioritize system unlock flow 2026-08-18 20:22:34 +08:00
bincxz 370b57fa67 style(app-lock): simplify password field 2026-08-18 19:43:54 +08:00
bincxz 8562a26315 style(app-lock): move lock icon into password field 2026-08-18 19:42:07 +08:00
bincxz 0ac5c728b2 style(app-lock): add lock badge to logo 2026-08-18 19:39:43 +08:00
bincxz ff40b4fcff refactor(app-lock): align settings with modal flow 2026-08-18 19:36:52 +08:00
bincxz 0ac30b5049 fix(app-lock): harden lock boundaries 2026-08-18 17:24:46 +08:00
bincxz 20c4c7791f Merge remote-tracking branch 'origin/main' into codex/pr-1609-hardening 2026-08-18 16:33:20 +08:00
bincxzandCursor d422a27715 Merge origin/main into feature/app-lock-screen
Reconcile the app-lock feature with main's refactored renderer
architecture (publishers/hosts/AppShell) and main-process changes:

- index.tsx/App.tsx: AppLockGate keeps ownership of useSettingsState and
  useAppLockState; new AppLockRuntimePublisher publishes the runtime via
  appRuntimeBridge; SettingsPublisher now binds the gate-owned settings.
- AppSideEffects: defer deep links, tray port-forward toggles, global
  hotkeys and window close commands while locked; flush on unlock.
- main.cjs: keep fresh-session re-lock before main's reusable-window
  fast path; before-quit guard delegates to handleBeforeQuit with main's
  async plugin-shutdown commitQuit and pending-update cancellation.
- globalShortcutBridge: keep locked-state tray redaction on top of
  main's deferred tray panel show logic.
- preload: adopt main's lastPayload replay for terminal popup config
  (superset of the gate-delay replay fix).
- vite config: keep dev CSP plugin, adopt main's warn-only stale
  optimize-dep handling; electron-builder: keep Windows Hello helper
  extraResource with main's CLI-controlled arch targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:30:30 +08:00
陈大猫 9eb548a778 Merge pull request #3052 from binaricat/fix/3051-ai-stream-thinking-timeout
fix(ai): do not abort active thinking streams at 2 minutes
2026-08-18 15:56:43 +08:00
bincxz 2c73442a87 fix(ai): do not abort active thinking streams at 2 minutes
The HTTP stream layer used a hard 120s total deadline, so long
reasoning that was still emitting tokens got cut off. Re-arm a
2-minute idle timer on incoming bytes and keep a 30-minute hard cap
for runaway streams.

Closes #3051
2026-08-18 15:53:08 +08:00
陈大猫 4913a043da Merge pull request #3050 from binaricat/fix/3049-mcp-omitted-arguments
fix(mcp): accept omitted arguments for no-arg tools
2026-08-18 15:04:52 +08:00
bincxz ba88ea7e4f fix(mcp): accept omitted arguments for no-arg tools
MCP allows clients to omit tools/call arguments or send {}. Empty Zod
object schemas rejected the omitted case, so external agents could not
call get_environment and other no-arg tools.

Closes #3049
2026-08-18 14:59:22 +08:00
陈大猫 1deea15e0f Merge pull request #3048 from binaricat/fix/3046-focus-hidden-pane-full-size
fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
2026-08-18 14:48:30 +08:00
bincxzandCursor d920d076d4 fix(workspace): hand focus to a remaining session when closing the focused pane
closeSessionWorkspaceLayoutState pruned the workspace tree but left
focusedSessionId pointing at the closed session. In a focus-mode workspace no
pane then qualified as visible, so the continuously rendered hidden panes all
painted on top of each other (reported in #3046 follow-up). Reassign focus to
a remaining session and drop closed ids from focusSessionOrder, matching the
detach path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:34:23 +08:00
bincxzandCursor 95606cb62c fix(terminal): drop stale full-size cache while a pane is split-visible
A pane that was once full-size kept those pixels in lastVisiblePaneSizeRef
through split view; after a viewport resize, switching back to focus pinned
the hidden pane (and its PTY) at the stale size. Split-visible panes now
invalidate the cache so hidden full-size layouts re-measure the current area.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:18:07 +08:00
bincxzandCursor 083ee55455 fix(terminal): never cache split-rect sizes as the hidden pane pinned size
Switching a workspace from split view to focus view left each newly hidden
pane pinned at the split fragment size cached in lastVisiblePaneSizeRef, so
the background PTY stayed narrow and the \r progress spam from #3046 could
still occur. Only full-size layouts feed the hidden-pane pinned size now.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:11:43 +08:00
陈大猫andCursor c4edefdbe4 feat(terminal): pass Esc+. through for readline yank-last-arg (#3047)
* feat(terminal): pass Esc+. through for readline yank-last-arg

Autocomplete was swallowing Escape, and macOS Option+. typed ≥ instead of Meta-., so the standard shell shortcut never reached bash/zsh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): allow Shift when Esc+_ yanks the last argument

Underscore is Shift+Minus, so rejecting all shiftKey follow-ups dropped M-_ after autocomplete dismissed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep Esc+_ armed across the Shift keydown

Typing underscore emits Shift before Minus. Clearing the Meta prefix on that modifier event dropped M-_ after autocomplete dismiss.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): do not treat Esc+. as typed input

Yank-last-arg rewrites the shell line. Walking the ESC+. bytes appended a literal period to the command buffer and left autocomplete's typed buffer marked reliable.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:59:56 +08:00
陈大猫andCursor b7325218fb feat(ssh): accept PuTTY-style CLI args from bastion hosts (#3044)
* feat(ssh): accept PuTTY-style CLI args from bastion hosts

Reuse the existing ssh:// and telnet:// deep-link path so PAM products that launch putty.exe -ssh user@host -P port -pw password can point at Netcatty instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): consume operands of ignored PuTTY flags

Unknown value-taking options such as -m and -D were skipped while their next token was treated as a host or port, which could connect the supplied password to the wrong endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject unsupported PuTTY protocols and unknown flags

-raw/-rlogin/-serial must not fall through to SSH with the supplied password, and valueless switches such as -batch must not consume the following host. Unknown dash flags are now rejected instead of guessed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject missing or empty PuTTY option operands

An empty or trailing -pw was turned into ssh://user:@host, which the deep-link path treats as no password and can then authenticate with a saved vault credential.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject unsupported -pwfile, -i, and -hostkey launches

Those options request credentials or a host-key pin this pipeline cannot honor, so connecting with -pw or a vault match would silently change the authentication the caller asked for.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): keep .app/.exe hosts and reject -loghost

Suffix filtering now only covers the Electron script argument, so destinations like prod.app are not dropped. -loghost is rejected because this pipeline cannot honor the logical host-key identity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): keep packaged argv[1] hosts like prod.js

Suffix filtering now applies only after an Electron binary, so a packaged launch can still use a first-position hostname that looks like a script.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:35:27 +08:00
bincxzandCursor 428df79d87 fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
Focus-mode panes are always viewed full-size, but hidden non-focused panes
were laid out at their split rects when hibernate is off (the default). The
continuously rendered xterm and the remote PTY shrank to a 1/N-width
fragment, so \r-refresh progress output (rsync --info=progress2) soft-wrapped
and left one scrollback line per refresh, flooding the terminal when the
host was focused again (#3046). Focus mode now never uses split geometry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:18:46 +08:00
陈大猫andCursor 074acc3bed feat(sftp): add right-click extract for archives (#3042)
* feat(sftp): add right-click extract for archives

Let SFTP list and tree views unpack zip/tar/gz archives in place via remote SSH exec (or local tar/unzip), so users do not need to switch to another file manager.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): keep extract from clobbering files or dying on unzip output

Write gzip/bzip2/xz results to a staging path and replace the destination only after a zero exit. Drain unzip progress instead of treating routine stdout as a hard failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): quote extract cleanup trap and use unique staging files

Remote gzip/bzip2/xz nested a quoted path inside trap, so names with spaces failed before decode. Staging now exclusively creates a unique sibling, and local .gz uses Node zlib.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): refuse single-file extract onto an existing directory

Remote mv would otherwise drop the staging file inside that directory and still report success.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): restore the previous file if Windows extract replace fails

Rename the destination aside before publishing the staging file, instead of deleting it first.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:00:16 +08:00
陈大猫 282d1570cd Merge pull request #3040 from binaricat/fix/3000-enter-strips-previous-line-highlight
fix(terminal): keep previous line highlighted when the Enter chunk carries a bare CR
2026-08-18 10:50:13 +08:00
bincxzandCursor 4cacfac04c fix(terminal): detect start-row mutations by fingerprint instead of enumerating controls
The backtracking regex cannot enumerate every control that homes the
cursor (DECOM, DECCOLM, ...). Snapshot the skipped start row's text before
a newline-prefixed write; if it changed, something climbed back onto that
row and it is restored and repainted with the rest of the range. This
closes the whole class instead of chasing individual sequences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:33:26 +08:00
陈大猫 a2532dde35 Merge pull request #3037 from binaricat/feat/3019-osc-desktop-notifications
feat(terminal): show desktop notifications for OSC 9/777/99
2026-08-18 10:26:50 +08:00
bincxzandCursor d91724e9ce fix(terminal): treat DECSTBM homing as cursor backtracking
Setting a scroll region (`CSI Ps;Ps r`) homes the cursor, so a
newline-prefixed chunk using DECSTBM can land back on the highlighted
starting row like CUU/VPA. Keep the pre-write restore and repaint the
start row for such chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:26:32 +08:00
bincxzandCursor 51a5f8b7aa fix(terminal): treat VPA as cursor backtracking for highlight restoration
`CSI Ps d` positions the cursor on an absolute row and can jump back onto
the highlighted starting row just like CUU/CUP, so newline-prefixed chunks
using VPA must also keep the pre-write restore and repaint the start row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:18:17 +08:00
陈大猫 3bcc01f134 Merge pull request #3038 from binaricat/fix/3007-search-copy-on-select
fix(terminal): do not copy SearchAddon match selections
2026-08-18 10:14:07 +08:00
陈大猫 63da7edd7d Merge pull request #3039 from binaricat/fix/3003-windows-cursor-cli-login-cmd
fix(ai): detect Cursor CLI login on Windows cmd shims
2026-08-18 10:12:34 +08:00
bincxzandCursor fea8444adc fix(terminal): restore the start row when a newline-prefixed chunk climbs back up
A chunk like `\r\n\x1b[1A\rOK\x1b[1B` (multi-line progress redraws) starts
with a line advance but returns to the previous row and partially
overwrites a highlighted keyword. The line-advance exemption skipped the
pre-write restore while the post-write repaint also skipped that row,
leaving the untouched cells with the injected color.

Keep the restore and include the start row in the repaint whenever the
chunk contains sequences that can move the cursor back up (CUU/CPL/CUP,
restore cursor, RI, DECRC).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:12:09 +08:00
bincxz 545178b201 fix(terminal): abort OSC notify scans on CAN and SUB
xterm ends an OSC unsuccessfully on CAN/SUB. The hibernate scanner
now does the same and keeps the following bytes for replay.
2026-08-18 10:06:35 +08:00
bincxz c75feb0c1a fix(terminal): release copy-on-select when the window blurs
Alt-tab drops the matching mouseup in Electron. Release the gesture
on window blur so a later SearchAddon select cannot stick.
2026-08-18 10:05:21 +08:00
bincxzandCursor 69c26101cd fix(terminal): keep previous line highlighted when Enter chunk carries a bare CR
A write chunk that starts with a line advance (bash 5.1+ fuses the echoed
newline with the bracketed-paste `\x1b[?2004l\r`, command output, and the
next prompt) restored the previous prompt row's highlight before writing,
while the post-write repaint deliberately skips that row. The keyword
highlight on the command line vanished until a background catch-up ran.

Only treat a chunk as an in-place rewrite of the cursor row when it does
not begin with a line advance: once the cursor leaves the row, later CR/EL
sequences can only touch rows the post-write repaint already covers.

Fixes #3000

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:03:01 +08:00
bincxz 9b0cbf0a4c fix(terminal): abort interrupted OSC notify scans
Address the second Codex pass on #3037. A non-ST ESC now ends an
in-flight OSC 9/777/99 the same way xterm does, so hibernated
replay keeps the following CSI/text. OSC 99 fragments are also
concatenated raw so kitty can split on whitespace.
2026-08-18 10:01:26 +08:00
bincxz f44e200874 fix(terminal): scope Select All copy-on-select pulse to one pane
A split workspace must not treat a sibling SearchAddon match as a
user selection when Select All runs in another terminal.
2026-08-18 10:00:53 +08:00
bincxz d7f12834df fix(terminal): keep Select All and touchcancel on copy-on-select
User-invoked Select All / Select Word now pulse the gesture so they
still copy. touchcancel releases the same way as touchend so an
interrupted touch cannot leave SearchAddon able to overwrite the
clipboard.
2026-08-18 09:56:10 +08:00
bincxz 3f919955fa fix(terminal): honor OSC notify policy while hibernated
Address Codex review on #3037. Disabled mode no longer marks tab
activity. Hidden hibernated sessions now scan incoming chunks for
OSC 9/777/99 so Codex can still raise a toast before the tab wakes.
2026-08-18 09:53:42 +08:00
bincxz 1fc22d4cad fix(terminal): release copy-on-select after late contextmenu
A post-mouseup contextmenu was re-arming the gesture with no later
pointer-up, so SearchAddon could keep copying. Pulse contextmenu and
observe it in document capture so tmux/vim select-word still counts.
2026-08-18 09:51:57 +08:00
bincxz 647970f697 fix(terminal): do not copy SearchAddon match selections
Copy-on-select listened to every xterm selection change. SearchAddon
selects the active match on find and again 200ms after resize/write, so
searching for a term then selecting other text left the search term on
the clipboard (issue #3007).

Only write the clipboard after a user pointer gesture on the terminal.
2026-08-18 09:34:41 +08:00
bincxz 2a52837657 feat(terminal): show desktop notifications for OSC 9/777/99
Closes #3019

Codex and other agents emit OSC 9 when a turn completes or approval
is needed, but Netcatty ignored those sequences. Register OSC 9, 777
notify, and kitty OSC 99 handlers, map them to Electron notifications,
and reuse tray focus so a click jumps back to the emitting session.
Ignore ConEmu OSC 9;n progress sequences so they do not become toasts.
2026-08-18 09:28:11 +08:00
陈大猫 d1912f539a Merge pull request #3036 from binaricat/fix/3023-copy-wrap-space
fix(terminal): keep wrap-boundary spaces when copying
2026-08-18 09:27:54 +08:00
bincxz 7a0443d39a fix(terminal): keep wrap-boundary spaces when copying
Closes #3023

A long line that wraps exactly on a space loses that space on copy
(hbase shell becomes hbaseshell). xterm.js 6 may serve a no-endColumn
translateToString(true) from its string cache via String.trimEnd(),
which cannot tell a real last-column space from empty-cell padding.
Prompt detection and similar callers populate that cache first, so
both the normalizer and raw getSelection drop the space.

Always pass an exclusive end column so the copy path uses
getTrimmedLength() instead of trimEnd().
2026-08-17 21:21:46 +08:00
bincxz c85ab87262 fix(app-lock): queue disable/reset; pin system-auth lock version
Put requestDisable/requestReset on the settings mutation queue so concurrent
timeout changes cannot re-enable App Lock after a successful disable. Capture
the runtime lock version before system auth and reject the prompt if the lock
epoch advanced (idle→background re-lock) while the dialog was open.
2026-08-06 07:16:24 +08:00
bincxz 90b05946d0 fix(app-lock): hide tray session details while locked
While the runtime is locked, tray panel/menu data is redacted to empty
sessions and port-forwards so cached host details are not visible.
2026-08-06 07:02:46 +08:00
bincxz 7c5b811c27 fix(app-lock): queue password changes; drop stale system-auth results
Password-change saves go through the settings mutation queue. System unlock
re-checks locked state after the OS prompt so a re-lock during the dialog
cannot unlock from a stale auth success.
2026-08-06 06:50:32 +08:00
bincxz 851e772026 fix(app-lock): close DevTools on lock; serialize settings; dedupe system unlock
Close open DevTools when locking, queue full RMW settings mutations, and share
one in-flight system-auth prompt across windows.
2026-08-06 06:41:09 +08:00
bincxz 820587b4f5 fix(app-lock): serialize settings writes; fix system-unlock test
Queue app-lock save mutations and use unique temp paths so concurrent
writes cannot ENOENT or clobber. Update the system-unlock toggle test to
match the already-enabled-while-unavailable disable path.
2026-08-06 06:31:13 +08:00
bincxz 81a62b7ae2 fix(app-lock): atomic settings save and block native close while locked
Write app-lock settings via temp file + rename to avoid truncating the
verifier on crash. Gate application-menu Close and window:close IPC while
locked so popups are not destroyed behind the overlay.
2026-08-06 06:21:53 +08:00
bincxz 79603979ef fix(app-lock): count minimized windows as active for fresh-session
isVisible is false for minimized BrowserWindows, which made Dock reopen
re-lock while a minimized main/session window was still open. Treat
isMinimized() as a live session, while still ignoring hidden prewarm
windows.
2026-08-06 06:04:39 +08:00
bincxz 4e6c323559 fix(app-lock): keep started terminal popups mounted across re-lock
Only defer the initial Terminal start while locked. After a popup session
has begun, keep ready true so re-lock does not unmount the live terminal.
2026-08-06 05:47:23 +08:00
bincxz 1e0988d5f0 fix(app-lock): defer terminal popup start until unlocked
Keep forceRenderChildren so popup config IPC still registers while locked,
but do not mark the popup ready (or mount Terminal/startup commands) until
allowTerminalStart is true after unlock.
2026-08-06 05:38:20 +08:00
bincxz 01d91f55a7 fix(app-lock): repair nested isAppLockOverlayActive imports
The overlay helper import was accidentally inserted inside open named
import lists, which broke TypeScript parsing. Move it to a top-level
import statement in SnippetsManager and SFTP keyboard shortcuts.
2026-08-06 05:25:09 +08:00
bincxz c7052d6150 test(app-lock): expect optimistic unlock to keep observed version
Align unit tests with the no-version-bump unlock path so concurrent
main-process re-locks can still win via selectPreferredRuntimeAppLockState.
2026-08-06 05:15:33 +08:00
bincxz 0f073702f7 fix(app-lock): do not bump runtime version on optimistic unlock
Optimistic unlock used current.version+1, which can outrank a concurrent
main-process re-lock and make refreshRuntimeState ignore the authoritative
locked state. Keep the observed version so real main updates always win.
2026-08-06 05:15:05 +08:00
bincxz 8a4bec65dc fix(app-lock): topmost overlay and gate remaining capture key handlers
Raise the lock screen to max z-index and hide the context-menu portal while
locked. Centralize isAppLockOverlayActive and gate ChatInput slash picker,
SFTP shortcuts, theme modal, and shortkey/hotkey recorders so earlier
window-capture listeners cannot steal unlock keystrokes.
2026-08-06 05:00:00 +08:00
bincxz 87430cc6fa fix(app-lock): ignore hidden prewarm windows for fresh-session lock
Settings prewarm stays as a hidden BrowserWindow and was blocking
ensureAppLockForFreshSession after the main window closed. Only visible
windows count as a live session for that emptiness check.
2026-08-06 04:49:54 +08:00
bincxz b3ec94b6ef fix(app-lock): gate window key recorders while lock overlay is open
Earlier-registered capture handlers (shortkey/hotkey recorders, snippet
save) run before the overlay's stopImmediatePropagation. Skip those
handlers when [data-app-lock-overlay] is present so password keystrokes
cannot mutate settings behind the lock screen.
2026-08-06 04:39:17 +08:00
bincxz 5967b8fe56 fix(app-lock): count aux windows for fresh session; allow disable unlock
Fresh-session re-lock now considers settings/tray/popup windows via
getAppLockReopenWindows so an open Settings UI is not interrupted.
System unlock toggle stays clickable when already enabled even if the
authenticator is currently unavailable, so users can turn it off.
2026-08-06 04:29:10 +08:00
bincxz 100394dce9 fix(app-lock): block app key handlers before window listeners
While locked, use window capture with stopImmediatePropagation so
shortkey/hotkey recorders never see lock-screen keys, and stop
document bubble so window-level shortcuts (e.g. Snippets save) cannot
run even when focus is on body.
2026-08-06 04:12:23 +08:00
bincxz 81e1f7797d fix(app-lock): stop lock-screen keystrokes reaching app shortcuts
inert does not block keydown bubbling to window listeners. While locked,
stop propagation for keys targeted at the overlay so panel shortcuts
(e.g. Snippets Ctrl/Cmd+S) cannot act behind the lock screen.
2026-08-06 03:48:19 +08:00
bincxz 1d81be94f4 fix(app-lock): emit reopen when showing the tray lock panel
Background-locked tray panel needs reopenSignal so system unlock can
auto-prompt when the tray panel is opened.
2026-08-06 03:33:15 +08:00
bincxz ffb25f81c5 fix(app-lock): block reload and DevTools menu while locked
Native View menu reload/toggleDevTools can bypass the lock overlay.
Gate those actions on app-lock runtime locked state.
2026-08-06 03:15:06 +08:00
bincxz 6d6d9cdf2f fix(app-lock): record activity when enabling from no verifier
Avoid scheduling an immediate idle lock after first-time password setup
when the app has been open longer than the selected timeout.
2026-08-06 03:04:26 +08:00
bincxz dbe1a7469b fix(app-lock): gate native window command-close while locked
Cmd/Ctrl+W from the app menu uses IPC, not keydown; ignore it while the
lock overlay is up so sessions cannot close behind the lock screen.
2026-08-06 02:46:32 +08:00
bincxz a3b4439723 fix(app-lock): redact password verifier hash/salt over IPC
getSettings/broadcasts replace real salt and hash with zeroed placeholders
so renderers keep presence checks without exposing offline verifiers.
2026-08-06 02:37:09 +08:00
bincxz 8c0b9c5a47 fix(app-lock): restore getEffectiveKnownHosts import in App
App still uses the helper for effectiveKnownHosts and sync payload; the
import cleanup left a ReferenceError on main-window render.
2026-08-06 02:22:09 +08:00
bincxz c3dcb5c326 fix(app-lock): raise lock overlay above portaled menus
Popovers/dropdowns use z-999999; lock was only z-200000 so open menus
could remain interactive above the lock screen.
2026-08-06 02:09:42 +08:00
bincxz b6aa0fbfe5 fix(app-lock): show native dirty-quit warning above lock overlay
Renderer toasts sit under AppLockOverlay, so unsaved-editor quit blocks
looked like a no-op while locked. Surface a native dialog above the lock.
2026-08-06 01:59:05 +08:00
bincxz 9942d0825c fix(app-lock): include app-content windows in reopen notifications
Detached session windows are app-content only; without reopenSignal they
never auto-prompt system unlock after a background re-lock.
2026-08-06 01:37:23 +08:00
bincxz 1d11ae4c00 fix(app-lock): mark lock dialog data-state=open for shortcut guards
Mounted shortcut hooks (e.g. SFTP) suppress when a dialog has
data-state=open. Without that marker, keyboard actions could still run
behind the app lock overlay when focus is not on the password field.
2026-08-06 01:18:09 +08:00
bincxz 0d1fd79326 fix(app-lock): keep settings sync in detached session windows
Session windows still need theme/font/language updates from the main
window. Disable only system side effects, not settings IPC/storage sync.
2026-08-06 00:59:20 +08:00
bincxz 2433fbae47 fix(app-lock): mount terminal popup children before lock init
System Manager terminal popups receive config immediately after loadURL.
AppLockGate used to withhold children until async lock state resolved,
so onTerminalPopupConfig could miss the one-shot IPC. Force-render the
popup route and keep the last preload config for late remounts.
2026-08-06 00:49:56 +08:00
bincxz 9b142ebf3f fix(app-lock): broadcast lock state to session app-content windows
Detached #/session-window windows register with registerAsMainWindow:false
and were omitted from app-lock runtime/settings broadcasts, so they stayed
interactive after a lock. Include getAppContentWindows() (deduped) in the
broadcast fan-out and cover session windows in the harness.
2026-08-06 00:35:12 +08:00
bincxz 25d6646ffe fix(app-lock): re-lock on macOS reopen after last window closes
On darwin, closing every window leaves the process alive while app-lock
runtime stays unlocked. Re-apply startup lock when the last window closes
and before creating a fresh main window with no app-content windows.
2026-08-06 00:13:51 +08:00
bincxz 193e5e8251 fix(app-lock): inert re-locked UI; defer tray port-forward until unlock
Mark mounted app children inert/aria-hidden/pointer-events-none while locked,
trap Tab inside the lock dialog, and suppress global hotkeys. Queue tray
port-forward toggles in main (and renderer) until the runtime unlocks.
Dirty-quit focus restore already verified.
2026-08-05 23:55:26 +08:00
bincxz 25cb79f602 fix(app-lock): focus dirty editor windows before aborting quit
When quit is cancelled because a renderer reports unsaved editors,
show and focus those windows first. A tray-hidden or unfocused main
window otherwise only posts a toast the user never sees, so quit
appears to fail silently.
2026-08-05 22:59:45 +08:00
bincxz 9e273aded1 fix(app-lock): keep ssh deep links queued until unlock
Defer first App mount (and rendererReady) for any lock reason so
background/idle locks cannot deliver ssh:// connects behind the overlay.
Wait indefinitely for renderer readiness and re-queue failed deliveries so
startup unlock after the old 15s timeout no longer drops deep links. Queue
renderer-side deep links while already-mounted sessions re-lock.
2026-08-05 22:48:28 +08:00
Zheng Liu 5a3ffe119d Merge upstream/main into feature/app-lock-screen 2026-06-30 09:44:01 +08:00
Zheng Liu 54b30bab7d chore: trigger CI for app lock PR 2026-06-30 09:17:39 +08:00
Zheng Liu 17b6130014 Respect Windows package arch in builder config 2026-06-29 20:55:38 +08:00
Zheng Liu e3b1547d59 Silence MSVC coroutine deprecation for Windows Hello helper 2026-06-29 20:42:31 +08:00
Zheng Liu 61ef47b39b Fix Windows Hello helper MSVC invocation 2026-06-29 20:31:28 +08:00
Zheng Liu b8bb91ec2e Initialize MSVC for Windows package builds 2026-06-29 20:18:52 +08:00
Zheng Liu 90db33c1b4 Fix system app-lock availability handling 2026-06-29 17:10:22 +08:00
Zheng Liu 6c49baf84a feat: add app lock system unlock auto prompt setting 2026-06-24 16:27:43 +08:00
Zheng Liu 46716f177c fix: refine app lock biometric unlock 2026-06-24 15:58:48 +08:00
Zheng Liu 41f8924209 fix: streamline app lock system unlock 2026-06-24 15:38:24 +08:00
Zheng Liu a4b83887f9 fix: initialize msvc for windows hello helper 2026-06-24 15:16:07 +08:00
Zheng Liu 1d3484fe4c fix: validate windows hello helper architecture 2026-06-24 14:59:08 +08:00
Zheng Liu 08032b01f7 fix: package windows hello helper per arch 2026-06-24 14:51:52 +08:00
Zheng Liu 0ea707f70f fix: harden app lock system unlock flow 2026-06-24 14:42:49 +08:00
Zheng Liu f6a49dbf0a feat: add app lock system unlock setting 2026-06-24 14:30:12 +08:00
Zheng Liu ba3e76ea12 test: cover app lock system unlock overlay 2026-06-24 14:27:42 +08:00
Zheng Liu 751aee07f3 feat: expose app lock system unlock state 2026-06-24 14:26:24 +08:00
Zheng Liu 41d3000334 feat: wire app lock system unlock ipc 2026-06-24 14:21:31 +08:00
Zheng Liu 4dcb06d39b feat: add app lock system auth bridge 2026-06-24 14:17:47 +08:00
Zheng Liu 8a49c5609d feat: add windows hello helper packaging 2026-06-24 14:14:44 +08:00
Zheng Liu 38f318bc57 feat: persist app lock system unlock setting 2026-06-24 14:12:40 +08:00
Zheng Liu 564095d28f fix: stabilize app lock ci checks 2026-06-24 09:48:08 +08:00
Zheng Liu 5be97aa5e0 merge: sync upstream main 2026-06-24 09:32:43 +08:00
Zheng Liu c186258abd feat: add app lock reset easter egg 2026-06-24 09:18:08 +08:00
Zheng Liu 86df78ec79 feat: allow disabling idle app lock 2026-06-23 17:15:46 +08:00
Zheng Liu 43f8a39fef chore: simplify app lock setup copy 2026-06-23 17:12:06 +08:00
Zheng Liu 559f2a4260 fix: clarify app lock password lifecycle 2026-06-23 16:59:17 +08:00
Zheng Liu 6571c7847d feat: clarify app lock setup flow 2026-06-23 16:46:42 +08:00
Zheng Liu cc20ca0832 fix: cancel tray reveal stale app lock hide 2026-06-23 16:13:56 +08:00
Zheng Liu 8528ec9801 test: tighten app lock minimize preservation 2026-06-23 15:59:10 +08:00
Zheng Liu a4e8958086 test: cover app lock minimize preservation 2026-06-23 15:56:41 +08:00
Zheng Liu a0581b5382 test: tighten app lock idle activity timing 2026-06-23 15:52:43 +08:00
Zheng Liu 8f35fe4b03 test: cover shared app lock idle activity 2026-06-23 15:49:22 +08:00
Zheng Liu fad39c00ca test: cover app lock password change authority 2026-06-23 15:44:51 +08:00
Zheng Liu ad90aa9854 test: harden app lock recovery regressions 2026-06-23 15:38:17 +08:00
Zheng Liu 984328becf test: extend app lock renderer recovery coverage 2026-06-23 15:05:07 +08:00
Zheng Liu 091ea93984 test: cover app lock renderer runtime flows 2026-06-23 15:01:30 +08:00
Zheng Liu 15b729ea6b fix: recreate crashed window on app lock reopen 2026-06-23 14:49:47 +08:00
Zheng Liu 408908079d feat: unify app lock runtime ownership 2026-06-23 14:48:25 +08:00
Zheng Liu faa683cd17 fix: route app lock bridge through state hook 2026-06-17 15:07:27 +08:00
Zheng Liu 2afefa5260 fix: delay renderer ready until app lock unlocks 2026-06-17 14:22:39 +08:00
Zheng Liu 02388f04b8 fix: gate app routes before startup unlock 2026-06-17 14:13:03 +08:00
Zheng Liu 34d3d7b16d fix: lock app when main window reopens 2026-06-17 13:56:47 +08:00
Zheng Liu 6d262276a7 fix: reschedule app lock idle checks 2026-06-17 13:49:01 +08:00
Zheng Liu b284774a1a feat: add app lock settings UI 2026-06-17 13:45:19 +08:00
Zheng Liu 4d54b12f56 feat: add app lock overlay 2026-06-17 13:40:09 +08:00
Zheng Liu e2c25508a1 feat: add app lock lifecycle state 2026-06-17 13:35:53 +08:00
Zheng Liu b0fafede80 feat: persist app lock settings 2026-06-17 13:27:48 +08:00
Zheng Liu 2072471467 feat: add app lock password verifier 2026-06-17 13:21:38 +08:00
251 changed files with 22156 additions and 804 deletions

No files matched your search

+6
View File
@@ -220,6 +220,12 @@ jobs:
node-version: 22
cache: npm
- name: Set up MSVC developer command prompt
if: matrix.name == 'windows'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Restore Electron download cache
uses: actions/cache@v6
with:
+1
View File
@@ -115,6 +115,7 @@ coverage
!/build/icons
!/build/installer.nsh
/electron/native/**/build
/electron/bridges/windowsHelloHelper/build/
/release
/out
/public/monaco
+30 -41
View File
@@ -4,6 +4,8 @@ import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { useSettingsChromeStore } from './application/state/settingsChromeStore';
import { I18nProvider } from './application/i18n/I18nProvider';
import type { useSettingsState } from './application/state/useSettingsState';
import type { useAppLockState } from './application/state/useAppLockState';
import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
@@ -12,19 +14,20 @@ import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticat
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { AppShell } from './application/app/AppShell';
import { AppLocalStateProvider } from './application/app/AppLocalState';
import { AppSideEffects } from './application/app/AppSideEffects';
import { SessionPublisher } from './application/app/publishers/SessionPublisher';
import { SettingsPublisher } from './application/app/publishers/SettingsPublisher';
import { VaultPublisher } from './application/app/publishers/VaultPublisher';
import { AppLockRuntimePublisher } from './application/app/publishers/AppLockRuntimePublisher';
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
let rendererReadySent = false;
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
/**
* Bridges the locale out of settings chrome without pulling App into a
@@ -49,29 +52,16 @@ function App() {
);
}
function AppWithProviders() {
/**
* `AppLockGate` (index.tsx) owns `useSettingsState` / `useAppLockState` so the
* lock overlay can render before app children mount, and it owns the
* splash-removal + rendererReady signal (deep links must wait for unlock).
* App forwards the gate's settings instance into SettingsPublisher so the
* runtime slot/context still publish a single settings runtime.
*/
function AppWithProviders({ settings, appLock }: { settings: SettingsState; appLock: AppLockState }) {
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
useEffect(() => {
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
}
if (!rendererReadySent) {
rendererReadySent = true;
netcattyBridge.get()?.rendererReady?.();
}
} catch {
// ignore
}
return () => {
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
};
}, []);
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
@@ -83,24 +73,23 @@ function AppWithProviders() {
useExternalMcpGrantPersister();
return (
<SettingsPublisher
enableSettingsSync={!isPeerSessionWindow}
enableSystemEffects={!isPeerSessionWindow}
>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
<SettingsPublisher settings={settings}>
<AppLockRuntimePublisher appLock={appLock} appLockEnabled={settings.appLockSettings.enabled}>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
</AppLockRuntimePublisher>
</SettingsPublisher>
);
}
@@ -10,6 +10,7 @@ const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
@@ -79,10 +80,12 @@ test('App.tsx does not subscribe to vault/session/settings runtimes or mega hook
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
const drainSource = source.slice(drainStart, drainEnd);
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
});
+102 -16
View File
@@ -7,10 +7,12 @@ import { useManagedSourceSync } from '../state/useManagedSourceSync';
import { usePortForwardingState } from '../state/usePortForwardingState';
import { useUpdateCheck } from '../state/useUpdateCheck';
import {
useAppLockChrome,
useAppSessionRuntime,
useAppSettingsRuntime,
useAppVaultRuntime,
} from '../state/appRuntimeBridge';
import { shouldDeferExternalActionWhileAppLocked } from '../../components/AppLockGate';
import {
getConnectionLogsSnapshot,
subscribeConnectionLogs,
@@ -101,6 +103,7 @@ const HOTKEY_DEBUG =
export function AppSideEffects() {
const settings = useAppSettingsRuntime();
const { locked: appLockLocked } = useAppLockChrome();
const { t } = useI18n();
const pluginViewTabs = usePluginViewTabs();
@@ -620,6 +623,20 @@ export function AppSideEffects() {
workspaces,
});
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
// Saved-credential tunnels must not start/stop behind the lock overlay.
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
const pending = pendingTrayPortForwardsWhileLockedRef.current;
const existing = pending.findIndex((item) => item.ruleId === ruleId);
if (existing >= 0) pending.splice(existing, 1);
pending.push({ ruleId, start });
return;
}
_handleTrayTogglePortForward(ruleId, start);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -629,7 +646,7 @@ export function AppSideEffects() {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
_handleTrayTogglePortForward(ruleId, start);
_handleTrayTogglePortForwardMaybeDeferred(ruleId, start);
});
return () => {
@@ -638,6 +655,14 @@ export function AppSideEffects() {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingTrayPortForwardsWhileLockedRef.current.splice(0);
for (const item of pending) {
_handleTrayTogglePortForward(item.ruleId, item.start);
}
}, [appLockLocked]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1094,20 +1119,24 @@ export function AppSideEffects() {
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
useEffect(() => {
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
// Gate it while locked so sessions/tabs cannot close behind the overlay.
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
void handleWindowCommandCloseRequest();
});
return () => unsubscribe?.();
}, [handleWindowCommandCloseRequest]);
}, [appLockLocked, handleWindowCommandCloseRequest]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
executeHotkeyAction(action, e);
}, [executeHotkeyAction]);
// Global hotkey handler
// Global hotkey handler — suppress while the app lock overlay is up so
// capture-phase shortcuts cannot mutate sessions behind the lock screen.
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
if (hotkeyScheme === 'disabled' || isHotkeyRecording || appLockLocked) return;
const handleGlobalKeyDown = (e: KeyboardEvent) => {
_handleGlobalHotkeyKeyDown(e);
@@ -1115,15 +1144,16 @@ export function AppSideEffects() {
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, isHotkeyRecording]);
}, [hotkeyScheme, isHotkeyRecording, appLockLocked]);
useEffect(() => {
if (appLockLocked) return;
const onKeyDown = (e: KeyboardEvent) => {
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
}, [appLockLocked]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -1390,7 +1420,17 @@ export function AppSideEffects() {
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
// Idle/background/manual locks keep children mounted under the overlay. Queue
// deep links until unlock so saved-credential connects cannot start behind
// the lock screen.
const pendingDeepLinksWhileLockedRef = useRef<Array<
| { kind: 'ssh'; payload: { url?: string } }
| { kind: 'telnet'; payload: { url?: string } }
| { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
@@ -1439,6 +1479,14 @@ export function AppSideEffects() {
setActiveTabId('vault');
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
return;
}
_processSshDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1448,7 +1496,7 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseTelnetDeepLink(rawUrl);
@@ -1486,6 +1534,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
return;
}
_processTelnetDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1495,7 +1551,7 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
@@ -1515,6 +1571,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'jms', payload: payload || {} });
return;
}
_processJmsDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1524,6 +1588,35 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path',
payload: payload || {},
});
return;
}
_processOpenTerminalPath(payload);
});
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingDeepLinksWhileLockedRef.current.splice(0);
for (const item of pending) {
if (item.kind === 'ssh') _processSshDeepLink(item.payload);
else if (item.kind === 'telnet') _processTelnetDeepLink(item.payload);
else if (item.kind === 'jms') _processJmsDeepLink(item.payload);
else _processOpenTerminalPath(item.payload);
}
}, [appLockLocked]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
@@ -1535,13 +1628,6 @@ export function AppSideEffects() {
});
}, [sessions]);
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
+11
View File
@@ -29,6 +29,7 @@ import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { getAppAppLockRuntime, useAppLockChrome } from '../state/appRuntimeBridge';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
@@ -301,6 +302,14 @@ function AppViewInner({ domains }: AppViewProps) {
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
// App Lock chrome: narrow slice published by AppLockRuntimePublisher; the
// lock action reads the full runtime imperatively so the callback stays
// stable across gate re-renders.
const { appLockEnabled } = useAppLockChrome();
const handleLockApp = useCallback(() => {
getAppAppLockRuntime()?.lockNow('manual');
}, []);
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -485,6 +494,8 @@ function AppViewInner({ domains }: AppViewProps) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
onLockApp={handleLockApp}
appLockEnabled={appLockEnabled}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
@@ -0,0 +1,53 @@
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
import {
AppLockChromeContext,
registerAppAppLockRuntime,
type AppAppLockRuntime,
} from '../../state/appRuntimeBridge';
export type AppLockRuntimePublisherProps = {
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
appLock: AppAppLockRuntime;
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
appLockEnabled: boolean;
children?: ReactNode;
};
/**
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
* slice goes on context so TopTabs / AppSideEffects re-render only when the
* lock state actually changes — the full runtime changes identity on every
* gate render.
*/
export function AppLockRuntimePublisher({
appLock,
appLockEnabled,
children,
}: AppLockRuntimePublisherProps) {
useLayoutEffect(() => {
registerAppAppLockRuntime(appLock);
}, [appLock]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppAppLockRuntime(null);
}, []);
const chrome = useMemo(
() => ({
appLockEnabled,
locked: appLock.locked,
initialized: appLock.initialized,
}),
[appLockEnabled, appLock.locked, appLock.initialized],
);
return (
<AppLockChromeContext.Provider value={chrome}>
{children}
</AppLockChromeContext.Provider>
);
}
@@ -3,33 +3,29 @@ import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
type AppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
/**
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
* owns `useSettingsState` so the lock overlay can render before app children
* mount; the publisher only binds the runtime slot and context.
*/
settings: AppSettingsRuntime;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
* `SessionPublisher` hand over their runtimes: a context for render-time reads
* and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
* happens inside `useSettingsState`, so this publisher only relocates the
* binding out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
@@ -20,6 +20,10 @@ const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
const appLockBridgeSource = readFileSync(
new URL('../state/useAppLockBridge.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
@@ -58,10 +62,10 @@ test('clone-session payload is consumed once even if the effect re-runs', () =>
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
+21 -3
View File
@@ -43,6 +43,10 @@ export const enAiMessages: Messages = {
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.openaiApi': 'OpenAI API format',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions works with most OpenAI-compatible endpoints. Responses can raise cache hit rates on relays that support /v1/responses.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
@@ -54,6 +58,7 @@ export const enAiMessages: Messages = {
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Local Ollama: http://localhost:11434/v1 (no API key). Ollama Cloud: https://ollama.com/v1 plus your cloud API key.',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
@@ -442,15 +447,28 @@ export const enAiMessages: Messages = {
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Ask before Netcatty writes (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Execute freely (Cursor CLI: agent + force)',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before writes',
'ai.chat.permAutoDesc': 'Run freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
'ai.chat.selectProvider': 'Select provider',
'ai.chat.selectProviderAndModel': 'Select provider and model',
'ai.chat.selectModel': 'Select model',
'ai.chat.searchModels': 'Search models',
'ai.chat.providers': 'Providers',
'ai.chat.models': 'Models',
'ai.chat.pinned': 'Pinned',
'ai.chat.useCustomModel': 'Use "{id}"',
'ai.chat.thinkingLevel': 'Thinking',
'ai.chat.thinkingOff': 'Off',
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
+85
View File
@@ -83,6 +83,32 @@ export const enCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// App Lock
'appLock.title': 'Netcatty is locked',
'appLock.reason.default': 'Enter your lock password to continue.',
'appLock.reason.startup': 'Netcatty locks each time this window opens.',
'appLock.reason.idle': 'Netcatty locked after the inactivity timeout.',
'appLock.reason.manual': 'Netcatty was locked manually.',
'appLock.passwordLabel': 'Lock password',
'appLock.passwordPlaceholder': 'Enter password to unlock',
'appLock.unlock': 'Unlock',
'appLock.unlocking': 'Unlocking...',
'appLock.usePassword': 'Use password instead',
'appLock.error.emptyPassword': 'Enter the lock password.',
'appLock.error.incorrectPassword': 'Incorrect lock password.',
'appLock.systemUnlock.unlockWith': 'Unlock with {label}',
'appLock.systemUnlock.preparing': 'Preparing {label}...',
'appLock.systemUnlock.verifying': 'Waiting for {label}...',
'appLock.systemUnlock.error': 'System unlock was not completed. Use the lock password to continue.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Reset App Lock',
'appLock.reset.description': 'This removes the current lock password and turns off App Lock. Set a new password from Settings when you need it again.',
'appLock.reset.cancel': 'Cancel',
'appLock.reset.confirm': 'Reset App Lock',
'appLock.reset.resetting': 'Resetting...',
'appLock.reset.error': 'Could not reset App Lock. Try again.',
'topTabs.lockApp': 'Lock Netcatty',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
@@ -118,6 +144,59 @@ export const enCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > App Lock
'settings.appLock.title': 'App Lock',
'settings.appLock.description': 'Require a local lock password when Netcatty opens, after inactivity, or when you lock it manually.',
'settings.appLock.setupTitle': 'Set a lock password',
'settings.appLock.setupDescription': 'Create the password below. App Lock turns on immediately after it is saved.',
'settings.appLock.manageTitle': 'App Lock is ready',
'settings.appLock.enabledStatus': 'Enabled. Netcatty locks on startup, after inactivity, and when you lock it manually.',
'settings.appLock.disabledStatus': 'Password saved, but App Lock is currently off.',
'settings.appLock.disableTitle': 'Turn off App Lock',
'settings.appLock.disableDescription': 'Enter the current lock password to stop locking Netcatty. The saved password will be removed; turning App Lock on again requires creating a new one.',
'settings.appLock.disable': 'Turn off App Lock',
'settings.appLock.disabling': 'Turning off...',
'settings.appLock.setupPasswordTitle': 'Create lock password',
'settings.appLock.setupPasswordDescription': 'This password is required to unlock Netcatty and to change or turn off App Lock later.',
'settings.appLock.changePasswordTitle': 'Change lock password',
'settings.appLock.changePasswordDescription': 'Enter the current lock password, then choose a new one.',
'settings.appLock.enable': 'Enable app lock',
'settings.appLock.enableDesc': 'Lock on startup and after the selected inactivity timeout.',
'settings.appLock.timeout': 'Inactivity timeout',
'settings.appLock.timeoutDesc': 'Lock after inactivity, or when the window is closed to the tray. Never skips both.',
'settings.appLock.timeout.0': 'Never lock automatically',
'settings.appLock.timeout.1': '1 minute',
'settings.appLock.timeout.5': '5 minutes',
'settings.appLock.timeout.15': '15 minutes',
'settings.appLock.timeout.30': '30 minutes',
'settings.appLock.timeout.60': '60 minutes',
'settings.appLock.systemUnlock.label': '{label} unlock',
'settings.appLock.systemUnlock.desc': 'Use {label} as an alternate way to unlock Netcatty on this device.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} is not available on this device right now.',
'settings.appLock.systemUnlock.unavailable': 'System unlock is not available on this device.',
'settings.appLock.systemUnlock.locked': 'Unlock Netcatty before changing system unlock.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Open {label} automatically',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Show the {label} prompt automatically when Netcatty is waiting to unlock.',
'settings.appLock.currentPassword': 'Current password',
'settings.appLock.currentPasswordPlaceholder': 'Required to disable or replace',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Current password to turn off App Lock',
'settings.appLock.currentPasswordForChangePlaceholder': 'Current password to change it',
'settings.appLock.newPassword': 'New password',
'settings.appLock.newPasswordPlaceholder': 'Set lock password',
'settings.appLock.confirmPassword': 'Confirm password',
'settings.appLock.confirmPasswordPlaceholder': 'Repeat password',
'settings.appLock.savePassword': 'Set and enable',
'settings.appLock.savingPassword': 'Saving...',
'settings.appLock.passwordSet': 'Password configured',
'settings.appLock.replacePassword': 'Replace password',
'settings.appLock.enableAfterPassword': 'Set a lock password before enabling app lock.',
'settings.appLock.localOnlyHint': 'Device-local privacy lock. It is not synced and is not an OS security boundary.',
'settings.appLock.validation.currentRequired': 'Enter the current lock password.',
'settings.appLock.validation.newRequired': 'Enter a new lock password.',
'settings.appLock.validation.confirmRequired': 'Confirm the new lock password.',
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
@@ -610,6 +689,12 @@ Highlight the focused split pane:
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
'settings.terminal.behavior.oscNotifications.desc':
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
'settings.terminal.behavior.oscNotifications.always': 'Always',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
+6 -1
View File
@@ -208,6 +208,7 @@ export const enVaultMessages: Messages = {
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.owner': 'Owner',
'sftp.columns.configure': 'Choose visible columns',
'sftp.columns.actions': 'Actions',
'sftp.sort.directoriesFirst': 'Folders first',
@@ -229,6 +230,10 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.extract': 'Extract here',
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
@@ -489,7 +494,7 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
+21 -3
View File
@@ -43,6 +43,10 @@ export const esAiMessages: Messages = {
'ai.providers.style.google': 'Compatible con Google',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
'ai.providers.openaiApi': 'Formato de API de OpenAI',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions funciona con la mayoría de endpoints compatibles con OpenAI. Responses puede mejorar la tasa de aciertos de caché en relés que admiten /v1/responses.',
'ai.providers.icon.change': 'Cambiar ícono',
'ai.providers.icon.upload': 'Subir imagen',
'ai.providers.icon.reset': 'Restablecer',
@@ -54,6 +58,7 @@ export const esAiMessages: Messages = {
'ai.providers.apiKey.decrypting': 'Descifrando...',
'ai.providers.baseUrl': 'URL base',
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Ollama local: http://localhost:11434/v1 (sin API key). Ollama Cloud: https://ollama.com/v1 y tu clave de Cloud.',
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
'ai.providers.defaultModel': 'Modelo predeterminado',
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
@@ -442,15 +447,28 @@ export const esAiMessages: Messages = {
'ai.chat.permObserver': 'Observador',
'ai.chat.permConfirm': 'Confirmar',
'ai.chat.permAuto': 'Automático',
'ai.chat.permObserverDesc': 'Solo lectura (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Preguntar antes de que Netcatty escriba (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Ejecutar libremente (Cursor CLI: agent + force)',
'ai.chat.permObserverDesc': 'Solo lectura',
'ai.chat.permConfirmDesc': 'Preguntar antes de escribir',
'ai.chat.permAutoDesc': 'Ejecutar libremente',
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
'ai.chat.noModel': 'Sin modelo',
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
'ai.chat.selectProvider': 'Seleccionar proveedor',
'ai.chat.selectProviderAndModel': 'Seleccionar proveedor y modelo',
'ai.chat.selectModel': 'Seleccionar modelo',
'ai.chat.searchModels': 'Buscar modelos',
'ai.chat.providers': 'Proveedores',
'ai.chat.models': 'Modelos',
'ai.chat.pinned': 'Fijados',
'ai.chat.useCustomModel': 'Usar "{id}"',
'ai.chat.thinkingLevel': 'Razonamiento',
'ai.chat.thinkingOff': 'Off',
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
+6
View File
@@ -610,6 +610,12 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
'settings.terminal.behavior.oscNotifications.off': 'Desactivado',
'settings.terminal.behavior.oscNotifications.unfocused': 'Si no tiene el foco',
'settings.terminal.behavior.oscNotifications.always': 'Siempre',
'settings.terminal.behavior.osc52Clipboard': 'Portapapeles OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Permite que los programas remotos (tmux, vim, etc.) accedan al portapapeles local mediante secuencias de escape OSC-52.',
+6 -1
View File
@@ -208,6 +208,7 @@ export const esVaultMessages: Messages = {
'sftp.columns.modified': 'Modificado',
'sftp.columns.size': 'Tamaño',
'sftp.columns.kind': 'Tipo',
'sftp.columns.owner': 'Propietario',
'sftp.columns.configure': 'Elegir columnas visibles',
'sftp.columns.actions': 'Acciones',
'sftp.sort.directoriesFirst': 'Carpetas primero',
@@ -229,6 +230,10 @@ export const esVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Mover',
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
'sftp.context.download': 'Descargar',
'sftp.context.extract': 'Extraer aquí',
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
@@ -489,7 +494,7 @@ export const esVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones.',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Explorador de Windows',
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
+21 -3
View File
@@ -24,6 +24,10 @@ export const ruAiMessages: Messages = {
'ai.providers.style.google': 'Совместимый с Google',
'ai.providers.style.inherited': 'авто',
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
'ai.providers.openaiApi': 'Формат API OpenAI',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions работает с большинством OpenAI-совместимых эндпоинтов. Responses может повысить попадание в кэш на ретрансляторах с поддержкой /v1/responses.',
'ai.providers.icon.change': 'Изменить иконку',
'ai.providers.icon.upload': 'Загрузить изображение',
'ai.providers.icon.reset': 'Сбросить',
@@ -35,6 +39,7 @@ export const ruAiMessages: Messages = {
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Локальный Ollama: http://localhost:11434/v1 (без API-ключа). Ollama Cloud: https://ollama.com/v1 и ключ Cloud.',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
@@ -394,15 +399,28 @@ export const ruAiMessages: Messages = {
'ai.chat.permObserver': 'Наблюдатель',
'ai.chat.permConfirm': 'Подтверждение',
'ai.chat.permAuto': 'Авто',
'ai.chat.permObserverDesc': 'Только чтение (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Спрашивать перед записью Netcatty (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Выполнять свободно (Cursor CLI: agent + force)',
'ai.chat.permObserverDesc': 'Только чтение',
'ai.chat.permConfirmDesc': 'Спрашивать перед записью',
'ai.chat.permAutoDesc': 'Выполнять свободно',
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
'ai.chat.noModel': 'Нет модели',
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
'ai.chat.selectProvider': 'Выберите провайдера',
'ai.chat.selectProviderAndModel': 'Выберите провайдера и модель',
'ai.chat.selectModel': 'Выберите модель',
'ai.chat.searchModels': 'Поиск моделей',
'ai.chat.providers': 'Провайдеры',
'ai.chat.models': 'Модели',
'ai.chat.pinned': 'Закреплённые',
'ai.chat.useCustomModel': 'Использовать «{id}»',
'ai.chat.thinkingLevel': 'Мышление',
'ai.chat.thinkingOff': 'Выкл.',
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
+85
View File
@@ -82,6 +82,32 @@ export const ruCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// App Lock
'appLock.title': 'Netcatty заблокирован',
'appLock.reason.default': 'Введите пароль блокировки, чтобы продолжить.',
'appLock.reason.startup': 'Netcatty блокируется при каждом открытии этого окна.',
'appLock.reason.idle': 'Netcatty заблокирован после периода бездействия.',
'appLock.reason.manual': 'Netcatty был заблокирован вручную.',
'appLock.passwordLabel': 'Пароль блокировки',
'appLock.passwordPlaceholder': 'Введите пароль для разблокировки',
'appLock.unlock': 'Разблокировать',
'appLock.unlocking': 'Разблокировка...',
'appLock.usePassword': 'Использовать пароль',
'appLock.error.emptyPassword': 'Введите пароль блокировки.',
'appLock.error.incorrectPassword': 'Неверный пароль блокировки.',
'appLock.systemUnlock.unlockWith': 'Разблокировать через {label}',
'appLock.systemUnlock.preparing': 'Подготовка {label}...',
'appLock.systemUnlock.verifying': 'Ожидание {label}...',
'appLock.systemUnlock.error': 'Системная разблокировка не завершена. Используйте пароль блокировки, чтобы продолжить.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Сбросить блокировку',
'appLock.reset.description': 'Это удалит текущий пароль блокировки и отключит App Lock. Новый пароль можно создать позже в настройках.',
'appLock.reset.cancel': 'Отмена',
'appLock.reset.confirm': 'Сбросить App Lock',
'appLock.reset.resetting': 'Сброс...',
'appLock.reset.error': 'Не удалось сбросить App Lock. Попробуйте ещё раз.',
'topTabs.lockApp': 'Заблокировать Netcatty',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
@@ -117,6 +143,59 @@ export const ruCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > App Lock
'settings.appLock.title': 'Блокировка приложения',
'settings.appLock.description': 'Требовать локальный пароль блокировки при открытии Netcatty, после бездействия или при ручной блокировке.',
'settings.appLock.setupTitle': 'Задайте пароль блокировки',
'settings.appLock.setupDescription': 'Создайте пароль ниже. Блокировка приложения включится сразу после сохранения.',
'settings.appLock.manageTitle': 'Блокировка приложения готова',
'settings.appLock.enabledStatus': 'Включена. Netcatty блокируется при запуске, после бездействия и при ручной блокировке.',
'settings.appLock.disabledStatus': 'Пароль сохранён, но блокировка приложения сейчас выключена.',
'settings.appLock.disableTitle': 'Отключить блокировку приложения',
'settings.appLock.disableDescription': 'Введите текущий пароль блокировки, чтобы Netcatty перестал блокироваться. Сохранённый пароль будет удалён; для повторного включения нужно создать новый.',
'settings.appLock.disable': 'Отключить блокировку',
'settings.appLock.disabling': 'Отключение...',
'settings.appLock.setupPasswordTitle': 'Создайте пароль блокировки',
'settings.appLock.setupPasswordDescription': 'Этот пароль нужен для разблокировки Netcatty, а также для изменения или отключения блокировки позже.',
'settings.appLock.changePasswordTitle': 'Изменить пароль блокировки',
'settings.appLock.changePasswordDescription': 'Введите текущий пароль блокировки, затем выберите новый.',
'settings.appLock.enable': 'Включить блокировку приложения',
'settings.appLock.enableDesc': 'Блокировать при запуске и после выбранного периода бездействия.',
'settings.appLock.timeout': 'Тайм-аут бездействия',
'settings.appLock.timeoutDesc': 'Блокировать после бездействия или при закрытии в трей. «Никогда» отключает оба варианта.',
'settings.appLock.timeout.0': 'Не блокировать автоматически',
'settings.appLock.timeout.1': '1 минута',
'settings.appLock.timeout.5': '5 минут',
'settings.appLock.timeout.15': '15 минут',
'settings.appLock.timeout.30': '30 минут',
'settings.appLock.timeout.60': '60 минут',
'settings.appLock.systemUnlock.label': 'Разблокировка через {label}',
'settings.appLock.systemUnlock.desc': 'Используйте {label} как альтернативный способ разблокировать Netcatty на этом устройстве.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} сейчас недоступен на этом устройстве.',
'settings.appLock.systemUnlock.unavailable': 'Системная разблокировка недоступна на этом устройстве.',
'settings.appLock.systemUnlock.locked': 'Разблокируйте Netcatty перед изменением системной разблокировки.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Открывать {label} автоматически',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Автоматически показывать запрос {label}, когда Netcatty ожидает разблокировки.',
'settings.appLock.currentPassword': 'Текущий пароль',
'settings.appLock.currentPasswordPlaceholder': 'Нужен для отключения или замены',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Текущий пароль для отключения блокировки',
'settings.appLock.currentPasswordForChangePlaceholder': 'Текущий пароль для изменения',
'settings.appLock.newPassword': 'Новый пароль',
'settings.appLock.newPasswordPlaceholder': 'Задайте пароль блокировки',
'settings.appLock.confirmPassword': 'Подтвердите пароль',
'settings.appLock.confirmPasswordPlaceholder': 'Повторите пароль',
'settings.appLock.savePassword': 'Задать и включить',
'settings.appLock.savingPassword': 'Сохранение...',
'settings.appLock.passwordSet': 'Пароль настроен',
'settings.appLock.replacePassword': 'Заменить пароль',
'settings.appLock.enableAfterPassword': 'Задайте пароль блокировки перед включением.',
'settings.appLock.localOnlyHint': 'Локальная приватная блокировка. Она не синхронизируется и не является системной границей безопасности.',
'settings.appLock.validation.currentRequired': 'Введите текущий пароль блокировки.',
'settings.appLock.validation.newRequired': 'Введите новый пароль блокировки.',
'settings.appLock.validation.confirmRequired': 'Подтвердите новый пароль блокировки.',
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
@@ -567,6 +646,12 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
+6 -1
View File
@@ -245,6 +245,7 @@ export const ruVaultMessages: Messages = {
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
'sftp.columns.kind': 'Тип',
'sftp.columns.owner': 'Владелец',
'sftp.columns.configure': 'Выбрать видимые столбцы',
'sftp.columns.actions': 'Действия',
'sftp.sort.directoriesFirst': 'Папки сверху',
@@ -266,6 +267,10 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Переместить',
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.extract': 'Распаковать здесь',
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
@@ -525,7 +530,7 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
@@ -13,6 +14,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
{ name: "es", messages: es },
];
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
@@ -61,6 +68,21 @@ test("localized settings include network proxy labels", () => {
}
});
test("localized settings include OSC desktop notification labels", () => {
const keys = [
"settings.terminal.behavior.oscNotifications",
"settings.terminal.behavior.oscNotifications.desc",
"settings.terminal.behavior.oscNotifications.off",
"settings.terminal.behavior.oscNotifications.unfocused",
"settings.terminal.behavior.oscNotifications.always",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
@@ -80,6 +102,73 @@ test("localized settings include terminal font weight option labels", () => {
}
});
test("all app lock strings are translated in every supported locale", () => {
const keys = [
"appLock.title",
"appLock.reason.default",
"appLock.reason.startup",
"appLock.reason.idle",
"appLock.reason.manual",
"appLock.passwordLabel",
"appLock.passwordPlaceholder",
"appLock.unlock",
"appLock.unlocking",
"appLock.error.emptyPassword",
"appLock.error.incorrectPassword",
"appLock.systemUnlock.unlockWith",
"appLock.systemUnlock.error",
"appLock.logoLabel",
"appLock.reset.title",
"appLock.reset.description",
"appLock.reset.cancel",
"appLock.reset.confirm",
"appLock.reset.resetting",
"appLock.reset.error",
"topTabs.lockApp",
"settings.appLock.title",
"settings.appLock.description",
"settings.appLock.enable",
"settings.appLock.enableDesc",
"settings.appLock.timeout",
"settings.appLock.timeoutDesc",
"settings.appLock.timeout.0",
"settings.appLock.timeout.1",
"settings.appLock.timeout.5",
"settings.appLock.timeout.15",
"settings.appLock.timeout.30",
"settings.appLock.timeout.60",
"settings.appLock.systemUnlock.label",
"settings.appLock.systemUnlock.desc",
"settings.appLock.systemUnlock.unavailableDesc",
"settings.appLock.systemUnlock.unavailable",
"settings.appLock.systemUnlock.locked",
"settings.appLock.systemUnlock.autoPrompt.label",
"settings.appLock.systemUnlock.autoPrompt.desc",
"settings.appLock.currentPassword",
"settings.appLock.currentPasswordPlaceholder",
"settings.appLock.newPassword",
"settings.appLock.newPasswordPlaceholder",
"settings.appLock.confirmPassword",
"settings.appLock.confirmPasswordPlaceholder",
"settings.appLock.savePassword",
"settings.appLock.savingPassword",
"settings.appLock.passwordSet",
"settings.appLock.replacePassword",
"settings.appLock.enableAfterPassword",
"settings.appLock.localOnlyHint",
"settings.appLock.validation.currentRequired",
"settings.appLock.validation.newRequired",
"settings.appLock.validation.confirmRequired",
"settings.appLock.validation.mismatch",
"settings.appLock.validation.incorrect",
];
for (const locale of APP_LOCK_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
+21 -3
View File
@@ -43,6 +43,10 @@ export const zhCNAiMessages: Messages = {
'ai.providers.style.google': 'Google 兼容',
'ai.providers.style.inherited': '默认',
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
'ai.providers.openaiApi': 'OpenAI 请求格式',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': '默认 Chat Completions,兼容大多数 OpenAI 兼容接口。部分中转站在 Responses(/v1/responses)上缓存命中率更高。',
'ai.providers.icon.change': '修改图标',
'ai.providers.icon.upload': '上传图片',
'ai.providers.icon.reset': '恢复默认',
@@ -54,6 +58,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
'ai.providers.baseUrl.ollamaHelp': '本地 Ollama:http://localhost:11434/v1(无需 API Key)。Ollama Cloud:https://ollama.com/v1,并填写 Cloud API Key。',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
@@ -442,15 +447,28 @@ export const zhCNAiMessages: Messages = {
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自动',
'ai.chat.permObserverDesc': '只读(Cursor CLI:Ask)',
'ai.chat.permConfirmDesc': 'Netcatty 写操作前确认(Cursor CLI:agent + force)',
'ai.chat.permAutoDesc': '自由执行(Cursor CLI:agent + force)',
'ai.chat.permObserverDesc': '只读',
'ai.chat.permConfirmDesc': '写操作前确认',
'ai.chat.permAutoDesc': '自由执行',
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
'ai.chat.selectProvider': '选择提供商',
'ai.chat.selectProviderAndModel': '选择提供商和模型',
'ai.chat.selectModel': '选择模型',
'ai.chat.searchModels': '搜索模型',
'ai.chat.providers': '提供商',
'ai.chat.models': '模型',
'ai.chat.pinned': '已固定',
'ai.chat.useCustomModel': '使用 “{id}”',
'ai.chat.thinkingLevel': '思考强度',
'ai.chat.thinkingOff': '关闭',
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
+84
View File
@@ -66,6 +66,32 @@ export const zhCNCoreMessages: Messages = {
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// App Lock
'appLock.title': 'Netcatty 已锁定',
'appLock.reason.default': '输入锁定密码以继续。',
'appLock.reason.startup': '每次打开此窗口时,Netcatty 都会锁定。',
'appLock.reason.idle': 'Netcatty 已因长时间无操作而锁定。',
'appLock.reason.manual': 'Netcatty 已被手动锁定。',
'appLock.passwordLabel': '锁定密码',
'appLock.passwordPlaceholder': '输入密码解锁',
'appLock.unlock': '解锁',
'appLock.unlocking': '正在解锁...',
'appLock.usePassword': '使用密码解锁',
'appLock.error.emptyPassword': '请输入锁定密码。',
'appLock.error.incorrectPassword': '锁定密码不正确。',
'appLock.systemUnlock.unlockWith': '使用 {label} 解锁',
'appLock.systemUnlock.preparing': '正在准备 {label}...',
'appLock.systemUnlock.verifying': '正在等待 {label}...',
'appLock.systemUnlock.error': '系统解锁未完成。请使用锁定密码继续。',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': '重置应用锁定',
'appLock.reset.description': '这会删除当前锁定密码并关闭应用锁定。需要再次使用时,请到设置里创建新密码。',
'appLock.reset.cancel': '取消',
'appLock.reset.confirm': '重置应用锁定',
'appLock.reset.resetting': '正在重置...',
'appLock.reset.error': '无法重置应用锁定,请重试。',
'topTabs.lockApp': '锁定 Netcatty',
// Settings shell
'settings.title': '设置',
'settings.tab.application': '应用',
@@ -115,6 +141,59 @@ export const zhCNCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > App Lock
'settings.appLock.title': '应用锁定',
'settings.appLock.description': '在 Netcatty 打开、长时间无操作或手动锁定后,要求输入本地锁定密码。',
'settings.appLock.setupTitle': '设置锁定密码',
'settings.appLock.setupDescription': '在下方创建密码,保存后会立即启用应用锁定。',
'settings.appLock.manageTitle': '应用锁定已准备就绪',
'settings.appLock.enabledStatus': '已启用。Netcatty 会在启动、长时间无操作或手动锁定时要求输入密码。',
'settings.appLock.disabledStatus': '密码已保存,但应用锁定当前关闭。',
'settings.appLock.disableTitle': '关闭应用锁定',
'settings.appLock.disableDescription': '输入当前锁定密码后停止锁定 Netcatty。已保存的密码会被删除;以后重新启用时需要创建新密码。',
'settings.appLock.disable': '关闭应用锁定',
'settings.appLock.disabling': '正在关闭...',
'settings.appLock.setupPasswordTitle': '创建锁定密码',
'settings.appLock.setupPasswordDescription': '此密码用于解锁 Netcatty,也用于以后修改或关闭应用锁定。',
'settings.appLock.changePasswordTitle': '修改锁定密码',
'settings.appLock.changePasswordDescription': '先输入当前锁定密码,再设置新密码。',
'settings.appLock.enable': '启用应用锁定',
'settings.appLock.enableDesc': '启动时锁定,并在达到所选无操作时间后锁定。',
'settings.appLock.timeout': '无操作超时',
'settings.appLock.timeoutDesc': '无操作时锁定,关闭到托盘时也会锁定。选择“不自动锁定”则两者都不会锁定。',
'settings.appLock.timeout.0': '不自动锁定',
'settings.appLock.timeout.1': '1 分钟',
'settings.appLock.timeout.5': '5 分钟',
'settings.appLock.timeout.15': '15 分钟',
'settings.appLock.timeout.30': '30 分钟',
'settings.appLock.timeout.60': '60 分钟',
'settings.appLock.systemUnlock.label': '{label} 解锁',
'settings.appLock.systemUnlock.desc': '在此设备上使用 {label} 作为解锁 Netcatty 的另一种方式。',
'settings.appLock.systemUnlock.unavailableDesc': '此设备当前无法使用 {label}。',
'settings.appLock.systemUnlock.unavailable': '此设备当前无法使用系统解锁。',
'settings.appLock.systemUnlock.locked': '请先解锁 Netcatty,再更改系统解锁。',
'settings.appLock.systemUnlock.autoPrompt.label': '自动弹出 {label}',
'settings.appLock.systemUnlock.autoPrompt.desc': '当 Netcatty 等待解锁时,自动显示 {label} 验证提示。',
'settings.appLock.currentPassword': '当前密码',
'settings.appLock.currentPasswordPlaceholder': '禁用或替换时需要',
'settings.appLock.currentPasswordForDisablePlaceholder': '用于关闭应用锁定的当前密码',
'settings.appLock.currentPasswordForChangePlaceholder': '用于修改密码的当前密码',
'settings.appLock.newPassword': '新密码',
'settings.appLock.newPasswordPlaceholder': '设置锁定密码',
'settings.appLock.confirmPassword': '确认密码',
'settings.appLock.confirmPasswordPlaceholder': '再次输入密码',
'settings.appLock.savePassword': '设置并启用',
'settings.appLock.savingPassword': '正在保存...',
'settings.appLock.passwordSet': '密码已配置',
'settings.appLock.replacePassword': '替换密码',
'settings.appLock.enableAfterPassword': '请先设置锁定密码,再启用应用锁定。',
'settings.appLock.localOnlyHint': '仅限本设备的隐私锁。不会同步,也不是系统级安全边界。',
'settings.appLock.validation.currentRequired': '请输入当前锁定密码。',
'settings.appLock.validation.newRequired': '请输入新的锁定密码。',
'settings.appLock.validation.confirmRequired': '请确认新的锁定密码。',
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
@@ -813,6 +892,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
'sftp.columns.owner': '所有者',
'sftp.columns.actions': '操作',
'sftp.sort.directoriesFirst': '目录置顶',
'sftp.emptyDirectory': '空目录',
@@ -833,6 +913,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.extract': '解压到当前目录',
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
+7 -1
View File
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
@@ -338,6 +338,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
'settings.terminal.behavior.oscNotifications.off': '关闭',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
'settings.terminal.behavior.oscNotifications.always': '始终',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
+21 -3
View File
@@ -43,6 +43,10 @@ export const zhTWAiMessages: Messages = {
'ai.providers.style.google': 'Google 相容',
'ai.providers.style.inherited': '預設',
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
'ai.providers.openaiApi': 'OpenAI 請求格式',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': '預設 Chat Completions,相容大多數 OpenAI 相容介面。部分中繼站在 Responses(/v1/responses)上快取命中率較高。',
'ai.providers.icon.change': '修改圖示',
'ai.providers.icon.upload': '上傳圖片',
'ai.providers.icon.reset': '恢復預設',
@@ -54,6 +58,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
'ai.providers.baseUrl.ollamaHelp': '本機 Ollama:http://localhost:11434/v1(不需 API Key)。Ollama Cloud:https://ollama.com/v1,並填寫 Cloud API Key。',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
@@ -441,15 +446,28 @@ export const zhTWAiMessages: Messages = {
'ai.chat.permObserver': '觀察',
'ai.chat.permConfirm': '確認',
'ai.chat.permAuto': '自動',
'ai.chat.permObserverDesc': '只讀(Cursor CLI:Ask)',
'ai.chat.permConfirmDesc': 'Netcatty 寫入前確認(Cursor CLI:agent + force)',
'ai.chat.permAutoDesc': '自由執行(Cursor CLI:agent + force)',
'ai.chat.permObserverDesc': '只讀',
'ai.chat.permConfirmDesc': '寫入前確認',
'ai.chat.permAutoDesc': '自由執行',
'ai.chat.emptyHint': '詢問伺服器相關問題、執行指令或取得設定協助。',
'ai.chat.placeholder': '向 {agent} 傳送訊息 — @ 引用上下文,/ 使用指令',
'ai.chat.placeholderDefault': '向 Catty Agent 傳送訊息...',
'ai.chat.noModel': '未選擇模型',
'ai.chat.noProviderModel': '未設定預設模型——前往 設定 → AI → 供應商 設定。',
'ai.chat.selectProvider': '選擇供應商',
'ai.chat.selectProviderAndModel': '選擇供應商與模型',
'ai.chat.selectModel': '選擇模型',
'ai.chat.searchModels': '搜尋模型',
'ai.chat.providers': '供應商',
'ai.chat.models': '模型',
'ai.chat.pinned': '已釘選',
'ai.chat.useCustomModel': '使用「{id}」',
'ai.chat.thinkingLevel': '思考強度',
'ai.chat.thinkingOff': '關閉',
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
+5
View File
@@ -813,6 +813,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.columns.modified': '修改時間',
'sftp.columns.size': '大小',
'sftp.columns.kind': '型別',
'sftp.columns.owner': '擁有者',
'sftp.columns.actions': '操作',
'sftp.sort.directoriesFirst': '資料夾置頂',
'sftp.emptyDirectory': '空目錄',
@@ -833,6 +834,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.moveTo.confirm': '移動',
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.extract': '解壓到目前目錄',
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
+7 -1
View File
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
@@ -338,6 +338,12 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
'settings.terminal.behavior.oscNotifications.off': '關閉',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
'settings.terminal.behavior.oscNotifications.always': '一律',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
'settings.terminal.behavior.osc52Clipboard.desc':
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
+43
View File
@@ -1,5 +1,6 @@
import { createContext, useContext } from 'react';
import type { useAppLockState } from './useAppLockState';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
@@ -7,6 +8,25 @@ import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
/**
* Narrow app-lock projection published on React context. The full runtime
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
* button, AppSideEffects deferral gates) only need these primitives, and
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
*/
export type AppLockChromeValue = {
appLockEnabled: boolean;
locked: boolean;
initialized: boolean;
};
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
appLockEnabled: false,
locked: false,
initialized: false,
};
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
@@ -69,6 +89,7 @@ class RuntimeSlot<T> {
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
@@ -106,9 +127,22 @@ export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
appLockSlot.set(runtime);
}
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
return appLockSlot.get();
}
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
return appLockSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
@@ -136,3 +170,12 @@ export function useAppSettingsRuntime(): AppSettingsRuntime {
}
return runtime;
}
/**
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
* Falls back to an "unlocked / disabled" default so windows without a
* publisher (isolated mounts, tests) behave as if App Lock is off.
*/
export function useAppLockChrome(): AppLockChromeValue {
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
}
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
handleTerminalOscNotification,
showOscDesktopNotification,
} from "./oscDesktopNotifications.ts";
const installNotificationBridge = () => {
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
netcatty: {
showSystemNotification: async (payload) => {
calls.push(payload);
return { shown: true };
},
},
};
return {
calls,
restore() {
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
else (globalThis as { window: unknown }).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
},
};
};
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
const fixture = installNotificationBridge();
try {
showOscDesktopNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "", body: "quiet", protocol: "osc9" },
mode: "unfocused",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
assert.deepEqual(fixture.calls, [{
title: "Codex",
body: "Turn complete",
sessionId: "s1",
}]);
} finally {
fixture.restore();
}
});
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
const fixture = installNotificationBridge();
let activity = 0;
try {
assert.equal(handleTerminalOscNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s-off",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), false);
assert.equal(activity, 0);
assert.equal(fixture.calls.length, 0);
assert.equal(handleTerminalOscNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: false,
sessionId: "s-on",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), true);
assert.equal(activity, 1);
assert.equal(fixture.calls.length, 1);
} finally {
fixture.restore();
}
});
@@ -0,0 +1,60 @@
import {
DEFAULT_OSC_NOTIFICATION_TITLE,
OscNotificationLimiter,
resolveOscNotificationPresentation,
shouldShowOscDesktopNotification,
type OscNotification,
} from "../../domain/terminalOscNotifications";
import type { OscNotificationMode } from "../../domain/models/terminal";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const sessionLimiters = new Map<string, OscNotificationLimiter>();
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
const existing = sessionLimiters.get(sessionId);
if (existing) return existing;
const limiter = new OscNotificationLimiter();
sessionLimiters.set(sessionId, limiter);
return limiter;
};
export function showOscDesktopNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
}): boolean {
if (!shouldShowOscDesktopNotification(options.mode, {
windowFocused: typeof document !== "undefined" && document.hasFocus(),
sessionFocused: options.sessionFocused,
})) {
return false;
}
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
const presented = resolveOscNotificationPresentation(
options.notification,
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
);
void netcattyBridge.get()?.showSystemNotification?.({
title: presented.title,
body: presented.body,
sessionId: options.sessionId,
});
return true;
}
export function handleTerminalOscNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
onSessionActivity?: () => void;
}): boolean {
if (options.mode === "off") return false;
options.onSessionActivity?.();
showOscDesktopNotification(options);
return true;
}
@@ -111,6 +111,8 @@ test("session peer windows do not run main-window startup effects", () => {
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
const appLockGateSource = readFileSync(new URL("../../components/AppLockGate.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
@@ -119,12 +121,17 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
assert.match(appLockGateSource, /settingsOptions\?: Parameters<typeof useSettingsState>\[0\]/);
assert.match(appLockGateSource, /deps\.useSettingsState\(settingsOptions\)/);
assert.match(indexSource, /const isPeerSessionWindow = window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(indexSource, /const settingsOptions = isPeerSessionWindow\s*\?\s*\{ enableSettingsSync: false, enableSystemEffects: false \}/);
assert.match(indexSource, /<AppLockGate settingsOptions=\{settingsOptions\}>/);
// AppLockGate owns useSettingsState; App forwards the gate's instance into
// SettingsPublisher so the runtime slot/context still publish it.
assert.match(appRootSource, /<SettingsPublisher settings=\{settings\}>/);
assert.doesNotMatch(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
/useSettingsState\(/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
@@ -125,6 +125,30 @@ test("closing a workspace session dissolves the workspace when one terminal rema
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s1",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
});
test("closing a non-focused session keeps the focused session", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
+18 -1
View File
@@ -96,7 +96,24 @@ export function closeSessionWorkspaceLayoutState(
return null;
}
return { ...workspace, root: prunedRoot };
// Closing the focused session must hand focus to a remaining one, or a
// focus-mode workspace is left with a dangling focusedSessionId: no pane
// qualifies as visible and the continuously rendered hidden panes all
// paint on top of each other (#3046).
const nextFocusedSessionId =
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
? remainingSessionIds[0]
: workspace.focusedSessionId;
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
return {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
};
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
+4 -1
View File
@@ -1,4 +1,4 @@
export type SortField = "name" | "size" | "modified" | "type";
export type SortField = "name" | "size" | "modified" | "type" | "owner";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
@@ -6,6 +6,7 @@ export interface ColumnWidths {
modified: number;
size: number;
type: number;
owner: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
@@ -15,6 +16,7 @@ export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
modified: true,
size: true,
type: true,
owner: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
@@ -28,5 +30,6 @@ export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibil
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
owner: stored.owner !== false,
};
};
@@ -28,6 +28,7 @@ export const useSftpDirectoryListing = () => {
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
owner: f.owner,
};
});
},
@@ -50,6 +51,7 @@ export const useSftpDirectoryListing = () => {
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
+4 -2
View File
@@ -31,10 +31,11 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 56,
modified: 28,
name: 50,
modified: 24,
size: 7,
type: 9,
owner: 10,
});
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
normalizeSftpColumnVisibility(
@@ -107,6 +108,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
owner: { min: 6, max: 20 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
+67 -5
View File
@@ -16,6 +16,7 @@ import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
} from '../../infrastructure/config/storageKeys';
@@ -79,6 +80,28 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
function providerPatchIsNoop(
current: ProviderConfig,
updates: Partial<ProviderConfig>,
): boolean {
for (const key of Object.keys(updates) as Array<keyof ProviderConfig>) {
const nextValue = updates[key];
const prevValue = current[key];
if (nextValue === prevValue) continue;
if (key === 'modelContextWindows') {
const prevWindows = (prevValue ?? {}) as Record<string, number>;
const nextWindows = (nextValue ?? {}) as Record<string, number>;
const nextKeys = Object.keys(nextWindows);
if (nextKeys.length !== Object.keys(prevWindows).length) return false;
if (nextKeys.some((modelId) => prevWindows[modelId] !== nextWindows[modelId])) return false;
continue;
}
return false;
}
return true;
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -174,6 +197,9 @@ export function useAIState() {
useEffect(() => {
agentProviderMapRef.current = agentProviderMap;
}, [agentProviderMap]);
const [agentThinkingMap, setAgentThinkingMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
@@ -270,6 +296,7 @@ export function useAIState() {
const setAgentModel = useCallback((agentId: string, modelId: string) => {
setAgentModelMapRaw(prev => {
if (prev[agentId] === modelId) return prev;
const next = { ...prev, [agentId]: modelId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
return next;
@@ -280,17 +307,36 @@ export function useAIState() {
setAgentProviderMapRaw(prev => {
// Empty string clears the per-agent override and lets the agent fall
// back to the global `activeProviderId`.
const next = { ...prev };
if (providerId) {
next[agentId] = providerId;
} else {
delete next[agentId];
if (prev[agentId] === providerId) return prev;
const next = { ...prev, [agentId]: providerId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
}
if (!(agentId in prev)) return prev;
const next = { ...prev };
delete next[agentId];
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
});
}, []);
const setAgentThinking = useCallback((agentId: string, thinkingLevel: string) => {
setAgentThinkingMapRaw((prev) => {
if (thinkingLevel) {
if (prev[agentId] === thinkingLevel) return prev;
const next = { ...prev, [agentId]: thinkingLevel };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
return next;
}
if (!(agentId in prev)) return prev;
const next = { ...prev };
delete next[agentId];
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -314,6 +360,7 @@ export function useAIState() {
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
if (next === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
@@ -493,6 +540,9 @@ export function useAIState() {
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
break;
case STORAGE_KEY_AI_AGENT_THINKING_MAP:
setAgentThinkingMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -1113,7 +1163,15 @@ export function useAIState() {
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
setProviders((prev) => {
const index = prev.findIndex((provider) => provider.id === id);
if (index < 0) return prev;
const current = prev[index];
if (providerPatchIsNoop(current, updates)) return prev;
const next = prev.slice();
next[index] = { ...current, ...updates };
return next;
});
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
@@ -1186,6 +1244,8 @@ export function useAIState() {
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -1244,6 +1304,8 @@ export function useAIState() {
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
webSearchConfig,
setWebSearchConfig,
quickMessages,
+79
View File
@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { RuntimeAppLockReason } from './useAppLockRuntime';
let rendererReadySent = false;
export function useAppLockBridge() {
const getRuntimeState = useCallback(async () => {
return netcattyBridge.get()?.getAppLockRuntimeState?.();
}, []);
const getSettings = useCallback(async () => {
return netcattyBridge.get()?.getAppLockSettings?.();
}, []);
const setRuntimeLocked = useCallback(async (reason: Exclude<RuntimeAppLockReason, null>) => {
return netcattyBridge.get()?.setAppLockRuntimeLocked?.(reason);
}, []);
const requestUnlock = useCallback(async (password: string) => {
return netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false as const, error: 'incorrect' as const };
}, []);
const requestReset = useCallback(async (currentPassword: string) => {
return netcattyBridge.get()?.requestAppLockReset?.(currentPassword);
}, []);
const reportActivity = useCallback(async () => {
await netcattyBridge.get()?.reportAppLockActivity?.();
}, []);
const onRuntimeStateChanged = useCallback((listener: (state: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockRuntimeStateChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const onSettingsChanged = useCallback((listener: (settings: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockSettingsChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const notifyRendererReady = useCallback(() => {
if (rendererReadySent) return;
rendererReadySent = true;
try {
netcattyBridge.get()?.rendererReady?.();
} catch {
// ignore
}
}, []);
const onAppLockReopen = useCallback((listener: () => void) => {
try {
return netcattyBridge.get()?.onAppLockReopen?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
return {
getRuntimeState,
getSettings,
setRuntimeLocked,
requestUnlock,
requestReset,
reportActivity,
onRuntimeStateChanged,
onSettingsChanged,
notifyRendererReady,
onAppLockReopen,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeRuntimeAppLockState,
selectPreferredRuntimeAppLockState,
} from "./useAppLockRuntime.ts";
test("normalizeRuntimeAppLockState defaults safely", () => {
assert.deepEqual(normalizeRuntimeAppLockState(null), {
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
});
test("normalizeRuntimeAppLockState preserves a valid runtime payload", () => {
assert.deepEqual(
normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
}),
{
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
},
);
});
test("selectPreferredRuntimeAppLockState keeps the newer runtime version", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastLockedAt: 100,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const staleIncoming = normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 50,
lastUnlockedAt: null,
lastActivityAt: 60,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, staleIncoming),
current,
);
});
test("selectPreferredRuntimeAppLockState prefers initialized state when versions tie", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const uninitializedIncoming = normalizeRuntimeAppLockState({
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, uninitializedIncoming),
current,
);
});
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState } from 'react';
export type RuntimeAppLockReason = 'startup' | 'idle' | 'manual' | 'background' | null;
export interface RuntimeAppLockState {
initialized: boolean;
locked: boolean;
reason: RuntimeAppLockReason;
version: number;
lastLockedAt: number | null;
lastUnlockedAt: number | null;
lastActivityAt: number | null;
}
export function normalizeRuntimeAppLockState(input: unknown): RuntimeAppLockState {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const reason = record?.reason;
return {
initialized: record?.initialized === true,
locked: record?.locked === true,
reason:
reason === 'startup' ||
reason === 'idle' ||
reason === 'manual' ||
reason === 'background'
? reason
: null,
version: typeof record?.version === 'number' && Number.isFinite(record.version) ? record.version : 0,
lastLockedAt: typeof record?.lastLockedAt === 'number' && Number.isFinite(record.lastLockedAt) ? record.lastLockedAt : null,
lastUnlockedAt: typeof record?.lastUnlockedAt === 'number' && Number.isFinite(record.lastUnlockedAt) ? record.lastUnlockedAt : null,
lastActivityAt: typeof record?.lastActivityAt === 'number' && Number.isFinite(record.lastActivityAt) ? record.lastActivityAt : null,
};
}
const DEFAULT_RUNTIME_APP_LOCK_STATE: RuntimeAppLockState = normalizeRuntimeAppLockState(null);
export function selectPreferredRuntimeAppLockState(
current: RuntimeAppLockState,
incoming: RuntimeAppLockState,
): RuntimeAppLockState {
if (incoming.version > current.version) return incoming;
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
return incoming;
}
export function useAppLockRuntime(
bridge: Pick<
NonNullable<NetcattyBridge>,
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
> | null | undefined,
) {
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(DEFAULT_RUNTIME_APP_LOCK_STATE);
const refreshRuntimeState = useCallback(async () => {
const nextState = await bridge?.getAppLockRuntimeState?.();
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
return normalized;
}, [bridge]);
useEffect(() => {
let cancelled = false;
const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
if (cancelled) return;
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
}) ?? (() => {});
void refreshRuntimeState().catch(() => {
if (cancelled) return;
setRuntimeState((current) => selectPreferredRuntimeAppLockState(
current,
DEFAULT_RUNTIME_APP_LOCK_STATE,
));
});
return () => {
cancelled = true;
unsubscribe();
};
}, [bridge, refreshRuntimeState]);
return {
runtimeState,
setRuntimeState,
refreshRuntimeState,
};
}
+182
View File
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AppLockPasswordVerifier, AppLockSettings } from "../../domain/appLock.ts";
import {
createOptimisticUnlockedRuntimeState,
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
getIdleLockDelayMs,
normalizeAppLockSystemUnlockStatus,
normalizeAppLockSystemUnlockResult,
resolveUnlockAttempt,
shouldLockAfterIdle,
shouldLockOnStartup,
} from "./useAppLockState.ts";
import { selectPreferredRuntimeAppLockState } from "./useAppLockRuntime.ts";
const verifier: AppLockPasswordVerifier = {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
};
test("shouldLockOnStartup locks only when enabled with a verifier", () => {
const enabled: AppLockSettings = {
enabled: true,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockOnStartup(enabled), true);
assert.equal(shouldLockOnStartup({ ...enabled, enabled: false }), false);
assert.equal(shouldLockOnStartup({ ...enabled, passwordVerifier: null }), false);
});
test("shouldLockAfterIdle honors the configured timeout", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000 - 1), false);
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000), true);
assert.equal(shouldLockAfterIdle({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, enabled: false }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, passwordVerifier: null }, 1_000, 1_000 + 60 * 60_000), false);
});
test("getIdleLockDelayMs schedules the next check after remaining idle time", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000), 5 * 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 4 * 60_000), 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 5 * 60_000), 0);
assert.equal(getIdleLockDelayMs({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, enabled: false }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, passwordVerifier: null }, 1_000, 1_000), null);
});
test("createOptimisticUnlockedRuntimeState clears stale locked state after successful unlock", () => {
const nextState = createOptimisticUnlockedRuntimeState(
{
initialized: false,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.deepEqual(nextState, {
initialized: true,
locked: false,
reason: null,
// Optimistic unlock must not invent a higher version than main has sent.
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: 5_000,
lastActivityAt: 5_000,
});
});
test("createOptimisticUnlockedRuntimeState keeps observed version so concurrent re-lock can win", () => {
const optimistic = createOptimisticUnlockedRuntimeState(
{
initialized: true,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.equal(optimistic.version, 7);
assert.equal(optimistic.locked, false);
// A concurrent main re-lock at version 8 must outrank the optimistic unlock.
const preferred = selectPreferredRuntimeAppLockState(optimistic, {
...optimistic,
locked: true,
reason: "manual",
version: 8,
lastLockedAt: 6_000,
});
assert.equal(preferred.locked, true);
assert.equal(preferred.version, 8);
});
test("normalizes app lock system unlock bridge status and results", () => {
assert.deepEqual(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS, {
supported: false,
available: false,
enabled: false,
platform: "unsupported",
label: null,
reason: null,
});
assert.deepEqual(
normalizeAppLockSystemUnlockStatus({
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: "",
}),
{
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: null,
},
);
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: true }), { ok: true });
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "cancelled" }), {
ok: false,
error: "cancelled",
});
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "unknown" }), {
ok: false,
error: "failed",
});
});
test("resolveUnlockAttempt validates empty, incorrect, and correct passwords", async () => {
const originalWindow = globalThis.window;
globalThis.window = {
netcatty: {
requestAppLockUnlock: async (password: string) =>
password === "secret"
? { ok: true as const }
: { ok: false as const, error: "incorrect" as const },
},
} as typeof window;
try {
assert.deepEqual(await resolveUnlockAttempt(""), { ok: false, error: "empty" });
assert.deepEqual(await resolveUnlockAttempt("wrong"), { ok: false, error: "incorrect" });
assert.deepEqual(await resolveUnlockAttempt("secret"), { ok: true });
} finally {
globalThis.window = originalWindow;
}
});
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
normalizeAppLockSettings,
type AppLockSettings,
} from '../../domain/appLock';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
normalizeRuntimeAppLockState,
useAppLockRuntime,
type RuntimeAppLockReason,
type RuntimeAppLockState,
} from './useAppLockRuntime';
export type AppLockReason = RuntimeAppLockReason;
export type AppLockUnlockResult =
| { ok: true }
| { ok: false; error: 'empty' | 'incorrect' };
export interface AppLockSystemUnlockStatus {
supported: boolean;
available: boolean;
enabled: boolean;
platform: 'darwin' | 'win32' | 'unsupported';
label: 'Touch ID' | 'Windows Hello' | null;
reason: string | null;
}
export type AppLockSystemUnlockResult =
| { ok: true }
| { ok: false; error: 'disabled' | 'not-locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' };
export const DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS: AppLockSystemUnlockStatus = {
supported: false,
available: false,
enabled: false,
platform: 'unsupported',
label: null,
reason: null,
};
export function normalizeAppLockSystemUnlockStatus(input: unknown): AppLockSystemUnlockStatus {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const platform = record?.platform === 'darwin' || record?.platform === 'win32'
? record.platform
: 'unsupported';
const label = record?.label === 'Touch ID' || record?.label === 'Windows Hello'
? record.label
: null;
return {
supported: record?.supported === true,
available: record?.available === true,
enabled: record?.enabled === true,
platform,
label,
reason: typeof record?.reason === 'string' && record.reason ? record.reason : null,
};
}
export function normalizeAppLockSystemUnlockResult(input: unknown): AppLockSystemUnlockResult {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
if (record?.ok === true) return { ok: true };
const error = record?.error;
if (
error === 'disabled' ||
error === 'not-locked' ||
error === 'unsupported' ||
error === 'unavailable' ||
error === 'cancelled' ||
error === 'failed'
) {
return { ok: false, error };
}
return { ok: false, error: 'failed' };
}
export function shouldLockOnStartup(settings: AppLockSettings): boolean {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled && normalized.passwordVerifier !== null;
}
export function shouldLockAfterIdle(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): boolean {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return false;
if (normalized.timeoutMinutes <= 0) return false;
return now - lastActivityAt >= normalized.timeoutMinutes * 60_000;
}
export function getIdleLockDelayMs(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): number | null {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return null;
if (normalized.timeoutMinutes <= 0) return null;
const timeoutMs = normalized.timeoutMinutes * 60_000;
return Math.max(0, timeoutMs - (now - lastActivityAt));
}
export async function resolveUnlockAttempt(password: string): Promise<AppLockUnlockResult> {
if (!password) return { ok: false, error: 'empty' };
try {
return await netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false, error: 'incorrect' };
} catch {
return { ok: false, error: 'incorrect' };
}
}
export function createOptimisticUnlockedRuntimeState(
input: RuntimeAppLockState,
now: number,
): RuntimeAppLockState {
const current = normalizeRuntimeAppLockState(input);
if (current.initialized && !current.locked && current.reason === null) {
return current;
}
return {
...current,
initialized: true,
locked: false,
reason: null,
// Keep the observed main-process version. Bumping here can outrank a real
// concurrent re-lock and leave the renderer unlocked after refresh (Codex P2).
version: current.version,
lastUnlockedAt: now,
lastActivityAt: now,
};
}
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
const bridge = netcattyBridge.get();
const { runtimeState, refreshRuntimeState, setRuntimeState } = useAppLockRuntime(bridge);
const [systemUnlockStatus, setSystemUnlockStatus] = useState<AppLockSystemUnlockStatus>(
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
);
const normalizedRuntimeState = useMemo(
() => normalizeRuntimeAppLockState(runtimeState),
[runtimeState],
);
const effectiveRuntimeState = useMemo(() => {
if (normalizedRuntimeState.initialized) return normalizedRuntimeState;
if (shouldLockOnStartup(normalizedSettings)) {
return {
...normalizedRuntimeState,
locked: true,
reason: 'startup' as const,
};
}
return normalizedRuntimeState;
}, [normalizedRuntimeState, normalizedSettings]);
const lockNow = useCallback((reason: AppLockReason = 'manual') => {
if (!shouldLockOnStartup(normalizedSettings) || !reason) return;
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
const unlock = useCallback(async (password: string): Promise<AppLockUnlockResult> => {
const result = await resolveUnlockAttempt(password);
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
return result;
}, [refreshRuntimeState, setRuntimeState]);
const refreshSystemUnlockStatus = useCallback(async () => {
try {
const nextStatus = await bridge?.getAppLockSystemUnlockStatus?.();
const normalized = normalizeAppLockSystemUnlockStatus(nextStatus);
setSystemUnlockStatus(normalized);
return normalized;
} catch {
setSystemUnlockStatus(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS);
return DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS;
}
}, [bridge]);
const unlockWithSystemAuth = useCallback(async (): Promise<AppLockSystemUnlockResult> => {
const result = normalizeAppLockSystemUnlockResult(await bridge?.requestAppLockSystemUnlock?.());
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
await refreshSystemUnlockStatus().catch(() => {});
return result;
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
const reset = useCallback(async (currentPassword: string) => {
if (typeof bridge?.requestAppLockReset !== 'function') {
throw new Error('App Lock reset bridge is unavailable');
}
const result = await bridge.requestAppLockReset(currentPassword);
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
throw new Error(result.error);
}
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}, [bridge, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings) && effectiveRuntimeState.locked) {
const unlockedAt = Date.now();
void bridge?.requestAppLockUnlock?.('')
?.then((result) => {
if (result?.ok !== true) return;
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
return refreshRuntimeState();
})
.catch(() => {});
}
}, [bridge, effectiveRuntimeState.locked, normalizedSettings, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'focus'];
for (const eventName of events) {
window.addEventListener(eventName, recordActivity, { passive: true });
}
return () => {
for (const eventName of events) {
window.removeEventListener(eventName, recordActivity);
}
};
}, [normalizedSettings, recordActivity]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
void bridge?.reportAppLockActivity?.();
return undefined;
}, [bridge, normalizedSettings]);
useEffect(() => {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
return {
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
resync: refreshRuntimeState,
};
}
+88
View File
@@ -11,6 +11,11 @@ import {
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
normalizeAppLockTimeoutMinutes,
type AppLockSettings,
type AppLockTimeoutMinutes,
} from '../../domain/appLock';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
@@ -338,6 +343,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [appLockSettings, setAppLockSettingsState] = useState<AppLockSettings>({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
@@ -787,6 +799,53 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const setAppLockTimeoutMinutes = useCallback((timeoutMinutes: AppLockTimeoutMinutes) => {
void netcattyBridge.get()?.setAppLockTimeoutMinutes?.(normalizeAppLockTimeoutMinutes(timeoutMinutes))
?.then((next) => {
if (next) setAppLockSettingsState(next);
})
.catch(() => {});
}, []);
const requestAppLockEnable = useCallback(async () => {
const next = await netcattyBridge.get()?.requestAppLockEnable?.();
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockDisable = useCallback(async (currentPassword: string) => {
const next = await netcattyBridge.get()?.requestAppLockDisable?.(currentPassword);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockPasswordChange = useCallback(async (input: {
currentPassword?: string;
nextPassword: string;
}) => {
const next = await netcattyBridge.get()?.requestAppLockPasswordChange?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const setAppLockSystemUnlockEnabled = useCallback(async (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => {
const next = await netcattyBridge.get()?.setAppLockSystemUnlockEnabled?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
const current = appearanceStateRef.current;
const nextAppearance = resolveAppearanceSyncState(
@@ -863,6 +922,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
void netcattyBridge.get()?.getAppLockSettings?.().then((next) => {
if (next) setAppLockSettingsState(next);
}).catch(() => {});
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
@@ -1155,6 +1218,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
};
}, [enableSettingsSync]);
useEffect(() => {
const bridge = netcattyBridge.get();
let sawPushedSettings = false;
const unsubscribe = bridge?.onAppLockSettingsChanged?.((next) => {
sawPushedSettings = true;
setAppLockSettingsState(next);
}) ?? (() => {});
void bridge?.getAppLockSettings?.().then((next) => {
if (!next || sawPushedSettings) return;
setAppLockSettingsState(next);
}).catch(() => {});
return () => {
unsubscribe();
};
}, []);
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -1914,6 +1996,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
updateSyncConfig,
uiLanguage,
setUiLanguage,
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockEnable,
requestAppLockDisable,
requestAppLockPasswordChange,
setAppLockSystemUnlockEnabled,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
+2
View File
@@ -234,6 +234,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
@@ -254,6 +255,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
agentThinkingMap: { catty: "high" },
webSearchConfig: webSearchWithoutKey,
showTerminalSelectionAction: false,
});
+7 -1
View File
@@ -102,6 +102,7 @@ import {
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
@@ -249,7 +250,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'autoCloseOnExit',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
@@ -313,6 +314,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
@@ -562,6 +564,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (agentModelMap) ai.agentModelMap = agentModelMap;
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const agentThinkingMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP);
if (agentThinkingMap) ai.agentThinkingMap = agentThinkingMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
@@ -796,6 +800,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
if (ai.agentThinkingMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, ai.agentThinkingMap);
if (ai.webSearchConfig !== undefined) {
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
@@ -847,6 +852,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (ai.agentThinkingMap != null) touched.push(STORAGE_KEY_AI_AGENT_THINKING_MAP);
// agentProviderMap is *always* potentially mutated because the reconcile
// step may have pruned it even if the payload didn't ship one.
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
+8 -1
View File
@@ -72,7 +72,9 @@ interface AIChatPanelContentProps {
cattyConfiguredProviders: ProviderConfig[];
effectiveActiveProvider?: ProviderConfig;
effectiveActiveModelId?: string;
handleAgentProviderModelSelect: (providerId: string, modelId: string) => void;
handleAgentProviderModelSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
selectedCattyThinking?: string;
handleCattyThinkingSelect?: (level: string) => void;
files: UploadedFile[];
addFiles: (inputFiles: File[]) => Promise<void>;
removeFile: (fileId: string) => void;
@@ -140,6 +142,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
effectiveActiveProvider,
effectiveActiveModelId,
handleAgentProviderModelSelect,
selectedCattyThinking,
handleCattyThinkingSelect,
files,
addFiles,
removeFile,
@@ -350,6 +354,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={providerSwitcher}
pickerScope={currentAgentId}
thinkingLevel={currentAgentId === 'catty' ? selectedCattyThinking : undefined}
onThinkingLevelChange={currentAgentId === 'catty' ? handleCattyThinkingSelect : undefined}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
@@ -102,6 +102,8 @@ const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePan
setAgentModel: () => undefined,
agentProviderMap: {},
setAgentProvider: () => undefined,
agentThinkingMap: {},
setAgentThinking: () => undefined,
globalPermissionMode: 'auto',
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
+72 -12
View File
@@ -39,6 +39,7 @@ import {
tryBeginSendForKey,
} from './ai/draftSendGate';
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
@@ -67,6 +68,9 @@ import {
buildSdkRuntimeModelCacheKey,
sdkRuntimeModelCache,
generateId,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
@@ -271,6 +275,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
updateProvider,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
@@ -760,12 +767,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
const providersRef = useRef(providers);
providersRef.current = providers;
const handleAgentProviderModelSelect = useCallback(
(providerId: string, modelId: string) => {
(providerId: string, modelId: string, contextWindow?: number) => {
setAgentProvider(currentAgentId, providerId);
setAgentModel(currentAgentId, modelId);
const sanitized = sanitizeContextWindow(contextWindow);
if (!updateProvider || sanitized == null) return;
const provider = providersRef.current.find((item) => item.id === providerId);
if (!provider) return;
if (provider.modelContextWindows?.[modelId] === sanitized) return;
updateProvider(providerId, {
modelContextWindows: {
...(provider.modelContextWindows ?? {}),
[modelId]: sanitized,
},
});
},
[currentAgentId, setAgentProvider, setAgentModel],
[currentAgentId, setAgentProvider, setAgentModel, updateProvider],
);
const providerDisplayName = effectiveActiveProvider?.name ?? '';
@@ -833,12 +854,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
};
}, []);
const externalAgentsRef = useRef(externalAgents);
externalAgentsRef.current = externalAgents;
const applySdkRuntimeModelCatalog = useCallback((
agentId: string,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
);
const storedModelId = agentModelMapRef.current[agentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
@@ -847,10 +875,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return rest;
});
} else {
setRuntimeAgentModelPresets((prev) => ({
...prev,
[agentId]: runtimePresets,
}));
setRuntimeAgentModelPresets((prev) => (
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
? prev
: { ...prev, [agentId]: runtimePresets }
));
}
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
storedModelId,
runtimePresets,
);
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
setAgentModel(agentId, normalizedStoredModelId);
return;
}
if (
@@ -988,7 +1026,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
return stored;
return normalizeStoredAgentModelSelection(stored, agentModelPresets) ?? stored;
}
if (agentModelPresets.length > 0) {
// Cursor CLI login defaults to `auto` (subscription quota routing).
@@ -1013,6 +1051,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
const selectedCattyThinking = agentThinkingMap.catty;
const handleCattyThinkingSelect = useCallback((level: string) => {
setAgentThinking('catty', level);
}, [setAgentThinking]);
const handleNewChat = useCallback(() => {
clearScopeDraft();
@@ -1148,7 +1191,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
if (catalog) {
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(
currentAgentConfig.command,
getExternalAgentSdkBackend(currentAgentConfig),
),
);
const storedModelId = agentModelMapRef.current[sendAgentId];
if (
catalog.currentModelId
@@ -1156,9 +1205,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
sendSelectedAgentModel = catalog.currentModelId;
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
sendSelectedAgentModel = storedModelId ?? sendSelectedAgentModel;
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
storedModelId,
runtimePresets,
) ?? storedModelId ?? sendSelectedAgentModel;
} else if (runtimePresets[0]) {
sendSelectedAgentModel = runtimePresets[0].id;
sendSelectedAgentModel = resolveAgentModelSelection(runtimePresets[0]);
}
}
}
@@ -1293,6 +1345,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider: sendActiveProvider,
activeModelId: sendActiveModelId,
reasoningEffort: selectedCattyThinking,
scopeType,
scopeTargetId,
scopeLabel,
@@ -1315,7 +1368,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1357,6 +1410,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
{
activeProvider: effectiveActiveProvider,
activeModelId: effectiveActiveModelId,
reasoningEffort: selectedCattyThinking,
scopeType,
scopeTargetId,
scopeLabel,
@@ -1390,6 +1444,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
currentAgentId,
effectiveActiveModelId,
effectiveActiveProvider,
selectedCattyThinking,
globalPermissionMode,
isStreaming,
scopeKey,
@@ -1642,6 +1697,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
selectedCattyThinking={selectedCattyThinking}
handleCattyThinkingSelect={handleCattyThinkingSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
@@ -1699,6 +1756,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'setAgentModel',
'agentProviderMap',
'setAgentProvider',
'agentThinkingMap',
'setAgentThinking',
'updateProvider',
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
+3
View File
@@ -68,6 +68,9 @@ export interface AIChatSidePanelProps {
setAgentModel: (agentId: string, modelId: string) => void;
agentProviderMap: Record<string, string>;
setAgentProvider: (agentId: string, providerId: string) => void;
agentThinkingMap: Record<string, string>;
setAgentThinking: (agentId: string, thinkingLevel: string) => void;
updateProvider?: (id: string, updates: Partial<ProviderConfig>) => void;
// Safety
globalPermissionMode: AIPermissionMode;
@@ -4,8 +4,12 @@ import assert from 'node:assert/strict';
import {
buildCursorListModelsAgentEnv,
buildSdkRuntimeModelCacheKey,
canonicalizeEffortEncodedModelId,
createSdkRuntimeModelCache,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
modelPresetsContainId,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
@@ -66,6 +70,46 @@ test('Cursor auth mode changes invalidate the SDK model cache key', () => {
assert.notEqual(apiKey, cliLogin);
});
test('canonicalizeEffortEncodedModelId collapses Cursor query-string effort', () => {
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low'), 'gpt-5/low');
assert.equal(canonicalizeEffortEncodedModelId('gpt-5/high'), 'gpt-5/high');
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low&mode=fast'), 'gpt-5?effort=low&mode=fast');
});
test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () => {
const merged = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'auto', name: 'Auto' }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(merged[0]?.thinkingLevels, ['low', 'medium', 'high']);
assert.equal(merged[0]?.defaultThinkingLevel, 'medium');
assert.equal(merged[1]?.thinkingLevels, undefined);
const partial = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
assert.equal(
mergeFallbackThinkingLevels(
alreadyFilled,
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'] }],
),
alreadyFilled,
);
});
test('agentModelPresetsShallowEqual ignores array identity when contents match', () => {
const left = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
const right = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
assert.equal(agentModelPresetsShallowEqual(left, right), true);
assert.equal(
agentModelPresetsShallowEqual(left, [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['high'] }]),
false,
);
});
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
const presets: AgentModelPreset[] = [
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
@@ -73,6 +117,7 @@ test('modelPresetsContainId matches plain and thinking-level model ids', () => {
];
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
assert.equal(modelPresetsContainId(presets, 'gpt-5.5?effort=high'), true);
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
});
@@ -125,6 +170,55 @@ test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is retu
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
});
test('normalizeStoredAgentModelSelection rewrites Cursor query effort but not extra params', () => {
const presets: AgentModelPreset[] = [{
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: ['low', 'medium', 'high'],
defaultThinkingLevel: 'medium',
}];
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5?effort=low', presets), 'gpt-5.5/low');
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5/high', presets), 'gpt-5.5/high');
assert.equal(
normalizeStoredAgentModelSelection('gpt-5.5?effort=low&mode=fast', presets),
undefined,
);
});
test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', () => {
const presets: AgentModelPreset[] = [{
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}];
assert.equal(normalizeStoredAgentModelSelection('glm-5.1', presets), 'glm-5.1');
assert.equal(normalizeStoredAgentModelSelection('glm-5.1/high', presets), 'glm-5.1/high');
});
test('legacy plain model selections keep their model and gain its default reasoning effort', () => {
const presets: AgentModelPreset[] = [{
id: 'grok-4.5',
name: 'Grok 4.5',
thinkingLevels: ['high', 'medium', 'low'],
defaultThinkingLevel: 'high',
}, {
id: 'grok-4.6',
name: 'Grok 4.6',
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
defaultThinkingLevel: 'high',
}];
assert.equal(modelPresetsContainId(presets, 'grok-4.5'), true);
assert.equal(normalizeStoredAgentModelSelection('grok-4.5', presets), 'grok-4.5/high');
assert.equal(normalizeStoredAgentModelSelection('grok-4.5/medium', presets), 'grok-4.5/medium');
assert.equal(
shouldAdoptSdkCurrentModel('grok-4.6/high', 'grok-4.5', presets),
false,
);
});
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
{ id: 'custom/provider-model', name: 'custom/provider-model' },
+68 -3
View File
@@ -1,5 +1,12 @@
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
import {
resolveAgentModelSelection,
type AgentModelPreset,
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
export { canonicalizeEffortEncodedModelId };
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
@@ -175,17 +182,75 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export function mergeFallbackThinkingLevels(
runtime: AgentModelPreset[],
fallbacks: AgentModelPreset[],
): AgentModelPreset[] {
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels?.length) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
return {
...preset,
thinkingLevels: [...fallback.thinkingLevels],
...(fallback.defaultThinkingLevel
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
: {}),
...(fallback.encodeDefaultThinking === false
? { encodeDefaultThinking: false }
: {}),
};
});
return changed ? next : runtime;
}
export function agentModelPresetsShallowEqual(
left: AgentModelPreset[] | undefined,
right: AgentModelPreset[] | undefined,
): boolean {
if (left === right) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((preset, index) => {
const other = right[index];
return (
preset.id === other.id
&& preset.name === other.name
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
);
});
}
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
const canonical = canonicalizeEffortEncodedModelId(modelId);
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
return preset.id === canonical
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
}
return preset.id === modelId;
return preset.id === canonical;
}
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function normalizeStoredAgentModelSelection(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
): string | undefined {
if (!storedModelId) return undefined;
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
if (!preset) return undefined;
return canonical === preset.id
? resolveAgentModelSelection(preset)
: canonical;
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
shouldDeferExternalActionWhileAppLocked,
shouldNotifyAppLockGateRendererReady,
shouldRenderAppLockGateChildren,
} from "./AppLockGate.tsx";
test("shouldRenderAppLockGateChildren withholds startup-locked route children until unlock", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "startup",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: false,
lockReason: null,
hasRenderedChildren: false,
}),
true,
);
});
test("shouldRenderAppLockGateChildren force-renders terminal popup children before init", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: false,
locked: true,
lockReason: "startup",
hasRenderedChildren: false,
forceRenderChildren: true,
}),
true,
);
});
test("shouldRenderAppLockGateChildren withholds first mount for idle and background locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "idle",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "background",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "manual",
hasRenderedChildren: false,
}),
false,
);
});
test("shouldRenderAppLockGateChildren withholds children before runtime initialization", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: false,
locked: false,
lockReason: null,
hasRenderedChildren: false,
}),
false,
);
});
test("shouldRenderAppLockGateChildren keeps mounted children for manual and idle locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "manual",
hasRenderedChildren: true,
}),
true,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "idle",
hasRenderedChildren: true,
}),
true,
);
});
test("shouldRenderAppLockGateChildren keeps existing children mounted for reopen locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "startup",
hasRenderedChildren: true,
}),
true,
);
});
test("shouldNotifyAppLockGateRendererReady waits until locked children can mount", () => {
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: true,
renderChildren: false,
}),
false,
);
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: true,
renderChildren: true,
}),
true,
);
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: false,
renderChildren: true,
}),
false,
);
});
test("shouldDeferExternalActionWhileAppLocked queues actions only while locked", () => {
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: true }), true);
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: false }), false);
});
+186
View File
@@ -0,0 +1,186 @@
import React, { useEffect, useRef, useState } from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { useAppLockBridge } from '../application/state/useAppLockBridge';
import { type AppLockReason, useAppLockState } from '../application/state/useAppLockState';
import { useSettingsState } from '../application/state/useSettingsState';
import { ToastProvider } from './ui/toast';
import { TooltipProvider } from './ui/tooltip';
import { AppLockOverlay } from './AppLockOverlay';
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
export interface AppLockGateRenderContext {
settings: SettingsState;
appLock: AppLockState;
}
interface AppLockGateProps {
children: (ctx: AppLockGateRenderContext) => React.ReactNode;
notifyRendererReady?: boolean;
/**
* Mount route children immediately (even before app-lock init / while locked).
* Terminal popups need this so their one-shot config IPC listeners register
* before main's post-loadURL send is only held in preload pending state —
* AppLockGate otherwise withholds children until async lock state resolves.
*/
forceRenderChildren?: boolean;
settingsOptions?: Parameters<typeof useSettingsState>[0];
}
interface AppLockGateDeps {
useSettingsState: typeof useSettingsState;
useAppLockState: typeof useAppLockState;
useAppLockBridge: typeof useAppLockBridge;
}
/**
* Withhold first content mount while the gate is locked for any reason
* (startup / idle / background / manual). A new renderer that reports ready
* before unlock would let main deliver ssh:// deep links into App handlers and
* start connections behind the lock overlay. Once children have mounted (e.g.
* idle re-lock after use), keep them mounted under the overlay.
*/
export function shouldRenderAppLockGateChildren(input: {
initialized: boolean;
locked: boolean;
lockReason: AppLockReason | null;
hasRenderedChildren: boolean;
forceRenderChildren?: boolean;
}): boolean {
if (input.forceRenderChildren) return true;
if (!input.initialized && !input.hasRenderedChildren) return false;
if (input.locked && !input.hasRenderedChildren) return false;
return true;
}
/**
* Deep-link delivery waits on rendererReady. Only mark the renderer ready once
* app children are allowed to mount, so unlock (not the 15s readiness timeout)
* is what unblocks pending ssh:// links after a locked first paint.
*/
export function shouldNotifyAppLockGateRendererReady(input: {
notifyRendererReady: boolean;
renderChildren: boolean;
}): boolean {
return input.notifyRendererReady && input.renderChildren;
}
/** Queue external actions (e.g. ssh:// deep links) while the app lock is held. */
export function shouldDeferExternalActionWhileAppLocked(input: {
locked: boolean;
}): boolean {
return input.locked === true;
}
export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGateProps> {
const AppLockGateImpl: React.FC<AppLockGateProps> = ({
children,
notifyRendererReady = true,
forceRenderChildren = false,
settingsOptions,
}) => {
const settings = deps.useSettingsState(settingsOptions);
const appLock = deps.useAppLockState(settings.appLockSettings);
const {
notifyRendererReady: notifyAppLockRendererReady,
onAppLockReopen,
} = deps.useAppLockBridge();
const hasRenderedChildrenRef = useRef(false);
const [reopenSignal, setReopenSignal] = useState(0);
const renderChildren = shouldRenderAppLockGateChildren({
initialized: appLock.initialized,
locked: appLock.locked,
lockReason: appLock.lockReason,
hasRenderedChildren: hasRenderedChildrenRef.current,
forceRenderChildren,
});
if (renderChildren) {
hasRenderedChildrenRef.current = true;
}
const shouldNotifyRendererReady = shouldNotifyAppLockGateRendererReady({
notifyRendererReady,
renderChildren,
});
useEffect(() => {
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
notifyAppLockRendererReady();
}
} catch {
// ignore
}
}, [notifyAppLockRendererReady, shouldNotifyRendererReady]);
useEffect(() => {
const unsubscribe = onAppLockReopen(() => {
setReopenSignal((current) => current + 1);
void appLock.resync?.();
});
return () => unsubscribe?.();
}, [appLock, onAppLockReopen]);
useEffect(() => {
const handleVisibilityOrFocus = () => {
void appLock.resync?.();
};
window.addEventListener('focus', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.removeEventListener('focus', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};
}, [appLock]);
// When re-locked after first mount, children stay mounted under the overlay.
// Mark them inert so pointer/keyboard/AT cannot reach underlying controls
// (sessions, editors, side panels) while the lock screen is up.
const lockedBackground = appLock.locked === true;
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
{renderChildren ? (
<div
// React 19 supports the HTML inert attribute; when true, the
// subtree is non-interactive and removed from sequential focus.
inert={lockedBackground ? true : undefined}
aria-hidden={lockedBackground || undefined}
className={lockedBackground ? 'pointer-events-none' : undefined}
data-app-lock-background={lockedBackground ? 'locked' : 'unlocked'}
>
{children({ settings, appLock })}
</div>
) : null}
<AppLockOverlay
locked={appLock.locked}
reason={appLock.lockReason}
onUnlock={appLock.unlock}
systemUnlockStatus={appLock.systemUnlockStatus}
onSystemUnlock={appLock.unlockWithSystemAuth}
onResetAppLock={appLock.reset}
autoPromptSystemUnlock={settings.appLockSettings.systemUnlockAutoPromptEnabled}
reopenSignal={reopenSignal}
/>
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);
};
return AppLockGateImpl;
}
export const AppLockGate = createAppLockGate({
useSettingsState,
useAppLockState,
useAppLockBridge,
});
+857
View File
@@ -0,0 +1,857 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
runWithAct,
} from "./test-support/renderReactDom.tsx";
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
await runWithAct(async () => {
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
});
await flushEffects();
await flushEffects();
}
test("AppLockOverlay shows incorrect-password error and clears it after editing", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const unlockAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async (password) => {
unlockAttempts.push(password);
return { ok: false, error: "incorrect" as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
const form = dom.document.querySelector("form");
const passwordLabel = dom.document.querySelector('label[for="app-lock-password"]');
const lockIcon = dom.document.querySelector('[data-testid="app-lock-password-lock-icon"]');
assert.ok(input);
assert.ok(form);
assert.ok(passwordLabel);
assert.ok(lockIcon);
assert.equal(input.getAttribute("placeholder"), "Enter password to unlock");
assert.match(passwordLabel.getAttribute("class") ?? "", /sr-only/);
assert.equal(lockIcon.getAttribute("aria-hidden"), "true");
assert.doesNotMatch(lockIcon.getAttribute("class") ?? "", /border-l/);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "wrong");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
await flushEffects();
await flushEffects();
assert.deepEqual(unlockAttempts, ["wrong"]);
assert.match(dom.document.body.textContent ?? "", /Incorrect lock password/i);
setInputValue.call(input, "wrong-again");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Incorrect lock password/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const resetAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async (currentPassword) => {
resetAttempts.push(currentPassword);
},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
assert.ok(input);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
assert.match(dom.document.body.textContent ?? "", /Reset App Lock/i);
const resetButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
assert.ok(resetButton);
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.deepEqual(resetAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay only reveals reset after five quick logo clicks", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => dom.window.setTimeout(resolve, 1600));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reset controls do not submit the unlock form", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let unlockCount = 0;
const resetAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => {
unlockCount += 1;
return { ok: false, error: "incorrect" as const };
},
onResetAppLock: async (currentPassword) => {
resetAttempts.push(currentPassword);
},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
assert.ok(input);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
}
await flushEffects();
const cancelButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Cancel/i.test(button.textContent ?? ""));
assert.ok(cancelButton);
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.equal(unlockCount, 0);
assert.deepEqual(resetAttempts, []);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
}
await flushEffects();
const resetButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
assert.ok(resetButton);
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(unlockCount, 0);
assert.deepEqual(resetAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay renders platform-specific system unlock button", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-windows-icon"]'));
const button = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Windows Hello/i.test(candidate.textContent ?? ""));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay localizes the system unlock button label", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "zh-CN" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: null,
},
onSystemUnlock: async () => ({ ok: true as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /使用 Touch ID 解锁/i);
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-touch-id-icon"]'));
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay does not automatically request system unlock by default", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
const props = {
locked: true,
reason: "manual" as const,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
};
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.match(dom.document.body.textContent ?? "", /Use password instead/i);
const button = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reveals password fallback and cancels a delayed automatic prompt", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
autoPromptSystemUnlock: true,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
const usePasswordButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Use password instead/i.test(candidate.textContent ?? ""));
assert.ok(usePasswordButton);
await dispatchDomEvent(usePasswordButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.ok(dom.document.getElementById("app-lock-password"));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 0);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay shows animated feedback while system unlock is in flight", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let resolveSystemUnlock: ((result: { ok: true }) => void) | null = null;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => new Promise<{ ok: true }>((resolve) => {
resolveSystemUnlock = resolve;
}),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const systemButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(systemButton);
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /Waiting for Touch ID/i);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
assert.ok(resolveSystemUnlock);
await runWithAct(async () => {
resolveSystemUnlock?.({ ok: true });
});
await flushEffects();
await flushEffects();
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay automatically requests system unlock once when auto prompt is enabled", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
const props = {
locked: true,
reason: "manual" as const,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
};
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay waits until the document is visible before auto system unlock", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
Object.defineProperty(dom.document, "visibilityState", {
configurable: true,
value: "hidden",
});
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
Object.defineProperty(dom.document, "visibilityState", {
configurable: true,
value: "visible",
});
await dispatchDomEvent(dom.document, new dom.window.Event("visibilitychange"));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay waits for window focus before auto system unlock", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let focused = false;
let systemUnlockCount = 0;
Object.defineProperty(dom.document, "hasFocus", {
configurable: true,
value: () => focused,
});
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 0);
focused = true;
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay retries a reopen presentation after an in-flight auto prompt finishes", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
let resolveFirstUnlock: ((result: { ok: false; error: "cancelled" }) => void) | null = null;
try {
const createOverlay = (reopenSignal: number) => React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "background",
reopenSignal,
autoPromptSystemUnlock: true,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
if (systemUnlockCount === 1) {
return new Promise<{ ok: false; error: "cancelled" }>((resolve) => {
resolveFirstUnlock = resolve;
});
}
return { ok: false as const, error: "cancelled" as const };
},
onResetAppLock: async () => {},
}),
);
await renderer.render(createOverlay(1));
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await renderer.render(createOverlay(2));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
assert.ok(resolveFirstUnlock);
await runWithAct(async () => {
resolveFirstUnlock?.({ ok: false, error: "cancelled" });
});
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 2);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay hides system unlock when unavailable", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: false,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: "unavailable",
},
onSystemUnlock: async () => ({ ok: true as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay keeps password fallback after system unlock failure", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const unlockAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async (password) => {
unlockAttempts.push(password);
return { ok: true as const };
},
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: null,
},
onSystemUnlock: async () => ({ ok: false as const, error: "cancelled" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.equal(dom.document.getElementById("app-lock-password"), null);
const systemButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(systemButton);
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /System unlock was not completed/i);
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
const form = dom.document.querySelector("form");
assert.ok(input);
assert.ok(form);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
await flushEffects();
assert.deepEqual(unlockAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
const form = overlay?.querySelector("form");
assert.ok(overlay);
assert.ok(form);
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
getAppLockErrorMessageKey,
} from "./AppLockOverlay.tsx";
test("getAppLockErrorMessageKey maps unlock errors to localized message keys", () => {
assert.equal(getAppLockErrorMessageKey("empty"), "appLock.error.emptyPassword");
assert.equal(getAppLockErrorMessageKey("incorrect"), "appLock.error.incorrectPassword");
assert.equal(getAppLockErrorMessageKey(null), null);
});
test("AppLockOverlay marks the backdrop as a window drag region", () => {
const source = readFileSync(new URL("./AppLockOverlay.tsx", import.meta.url), "utf8");
assert.match(
source,
/className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"/,
);
assert.match(
source,
/className="flex w-full max-w-\[360px\] flex-col items-center gap-5 app-no-drag"/,
);
});
+522
View File
@@ -0,0 +1,522 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Fingerprint, KeyRound, Loader2, LockKeyhole } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import type {
AppLockReason,
AppLockSystemUnlockResult,
AppLockSystemUnlockStatus,
AppLockUnlockResult,
} from '../application/state/useAppLockState';
import { AppLogo } from './AppLogo';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
const RESET_REVEAL_CLICK_COUNT = 5;
const RESET_REVEAL_WINDOW_MS = 1500;
export const APP_LOCK_AUTO_PROMPT_DELAY_MS = 1200;
function isWindowInteractive(): boolean {
return typeof document === 'undefined'
|| (
document.visibilityState !== 'hidden'
&& (typeof document.hasFocus !== 'function' || document.hasFocus())
);
}
interface AppLockOverlayProps {
locked: boolean;
reason: AppLockReason | null;
onUnlock: (password: string) => Promise<AppLockUnlockResult>;
systemUnlockStatus?: AppLockSystemUnlockStatus;
onSystemUnlock?: () => Promise<AppLockSystemUnlockResult>;
onResetAppLock: (currentPassword: string) => Promise<void>;
autoPromptSystemUnlock?: boolean;
reopenSignal?: number;
}
export function getAppLockErrorMessageKey(error: AppLockUnlockResult['error'] | null): string | null {
switch (error) {
case 'empty':
return 'appLock.error.emptyPassword';
case 'incorrect':
return 'appLock.error.incorrectPassword';
default:
return null;
}
}
export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
locked,
reason,
onUnlock,
systemUnlockStatus,
onSystemUnlock,
onResetAppLock,
autoPromptSystemUnlock = false,
reopenSignal = 0,
}) => {
const { t } = useI18n();
const passwordRef = useRef<HTMLInputElement>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState<AppLockUnlockResult['error'] | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSystemUnlocking, setIsSystemUnlocking] = useState(false);
const [isAutoPromptPending, setIsAutoPromptPending] = useState(false);
const [systemUnlockError, setSystemUnlockError] = useState(false);
const [passwordFallbackRequested, setPasswordFallbackRequested] = useState(false);
// Value is only read inside functional updates; keep the setter alone.
const [, setLogoClickCount] = useState(0);
const [lastLogoClickAt, setLastLogoClickAt] = useState<number | null>(null);
const [showReset, setShowReset] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [resetError, setResetError] = useState(false);
const [windowInteractive, setWindowInteractive] = useState(() => isWindowInteractive());
const lastAutoUnlockPresentationRef = useRef<string | null>(null);
const autoUnlockTimerRef = useRef<number | null>(null);
const canUseSystemUnlock = Boolean(
systemUnlockStatus?.enabled
&& systemUnlockStatus.available
&& systemUnlockStatus.label
&& onSystemUnlock,
);
const showPasswordUnlock = !canUseSystemUnlock
|| passwordFallbackRequested
|| systemUnlockError
|| showReset;
useEffect(() => {
const updateWindowInteractive = () => {
setWindowInteractive(isWindowInteractive());
};
updateWindowInteractive();
document.addEventListener('visibilitychange', updateWindowInteractive);
window.addEventListener('focus', updateWindowInteractive);
window.addEventListener('blur', updateWindowInteractive);
return () => {
document.removeEventListener('visibilitychange', updateWindowInteractive);
window.removeEventListener('focus', updateWindowInteractive);
window.removeEventListener('blur', updateWindowInteractive);
};
}, []);
const overlayRootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!locked) {
if (autoUnlockTimerRef.current !== null) {
window.clearTimeout(autoUnlockTimerRef.current);
autoUnlockTimerRef.current = null;
}
setPassword('');
setError(null);
setIsSubmitting(false);
setIsSystemUnlocking(false);
setIsAutoPromptPending(false);
setSystemUnlockError(false);
setPasswordFallbackRequested(false);
setLogoClickCount(0);
setLastLogoClickAt(null);
setShowReset(false);
setIsResetting(false);
setResetError(false);
lastAutoUnlockPresentationRef.current = null;
return;
}
if (!showPasswordUnlock) return;
const timeout = window.setTimeout(() => passwordRef.current?.focus(), 0);
return () => window.clearTimeout(timeout);
}, [locked, showPasswordUnlock]);
useEffect(() => {
if (!locked || reason !== 'background' || reopenSignal <= 0) return;
setPasswordFallbackRequested(false);
setSystemUnlockError(false);
}, [locked, reason, reopenSignal]);
// Soft focus trap + stop ALL app-level keydown while locked.
// inert does not block window capture (shortkey/hotkey recorders) or window
// bubble (Snippets Ctrl/Cmd+S). Use window capture + stopImmediate so peer
// window handlers never run, without stopPropagation so the lock password
// input still receives the event. Document bubble then stops before window
// bubble listeners (Codex P2 on 81e1f779).
useEffect(() => {
if (!locked) return;
const onWindowCapture = (event: KeyboardEvent) => {
// Suppress other window-level keydown handlers registered after us.
event.stopImmediatePropagation();
if (event.key !== 'Tab') return;
const root = overlayRootRef.current;
if (!root) return;
const focusable = root.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), [href], select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!active || !root.contains(active)) {
event.preventDefault();
first.focus();
return;
}
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
// Unconditional: even when focus is on body, window bubble shortcuts must
// not fire. Runs on document bubble before window bubble.
const onDocumentBubble = (event: KeyboardEvent) => {
event.stopPropagation();
};
window.addEventListener('keydown', onWindowCapture, true);
document.addEventListener('keydown', onDocumentBubble, false);
return () => {
window.removeEventListener('keydown', onWindowCapture, true);
document.removeEventListener('keydown', onDocumentBubble, false);
};
}, [locked]);
// Context menus portal at z-index max (2147483647). Hide it while locked so
// open right-click menus cannot sit above the lock screen (Codex P2).
useEffect(() => {
if (!locked) return;
const portal = document.getElementById('netcatty-context-menu-root');
if (!portal) return;
const prev = portal.style.visibility;
portal.style.visibility = 'hidden';
portal.style.pointerEvents = 'none';
return () => {
portal.style.visibility = prev;
portal.style.pointerEvents = 'none'; // portal default is none; children re-enable
};
}, [locked]);
const clearAutoUnlockTimer = useCallback(() => {
if (autoUnlockTimerRef.current !== null) {
window.clearTimeout(autoUnlockTimerRef.current);
autoUnlockTimerRef.current = null;
}
setIsAutoPromptPending(false);
}, []);
const handleSystemUnlock = useCallback(async () => {
if (isSystemUnlocking || !onSystemUnlock) return;
clearAutoUnlockTimer();
setIsSystemUnlocking(true);
setSystemUnlockError(false);
try {
const result = await onSystemUnlock();
if (!result.ok) {
setSystemUnlockError(true);
setPasswordFallbackRequested(true);
}
} catch {
setSystemUnlockError(true);
setPasswordFallbackRequested(true);
} finally {
setIsSystemUnlocking(false);
}
}, [clearAutoUnlockTimer, isSystemUnlocking, onSystemUnlock]);
const requestAutomaticSystemUnlock = useCallback(() => {
if (isSystemUnlocking || !onSystemUnlock) return false;
void handleSystemUnlock();
return true;
}, [handleSystemUnlock, isSystemUnlocking, onSystemUnlock]);
useEffect(() => {
if (!locked) return;
if (!autoPromptSystemUnlock) return;
if (!windowInteractive) return;
if (showReset) return;
if (isSystemUnlocking) return;
if (!systemUnlockStatus?.enabled || !systemUnlockStatus.available || !systemUnlockStatus.label) return;
if (!onSystemUnlock) return;
const presentationKey = reason === 'background'
? `background:${reopenSignal}`
: `foreground:${reason ?? 'default'}:${locked}`;
if (reason === 'background' && reopenSignal <= 0) return;
if (
passwordFallbackRequested
&& lastAutoUnlockPresentationRef.current === presentationKey
) return;
if (lastAutoUnlockPresentationRef.current === presentationKey) return;
if (autoUnlockTimerRef.current !== null) return;
if (passwordFallbackRequested || systemUnlockError) {
setPasswordFallbackRequested(false);
setSystemUnlockError(false);
}
setIsAutoPromptPending(true);
const timer = window.setTimeout(() => {
if (autoUnlockTimerRef.current !== timer) return;
autoUnlockTimerRef.current = null;
setIsAutoPromptPending(false);
if (!requestAutomaticSystemUnlock()) return;
lastAutoUnlockPresentationRef.current = presentationKey;
}, APP_LOCK_AUTO_PROMPT_DELAY_MS);
autoUnlockTimerRef.current = timer;
return () => {
if (autoUnlockTimerRef.current !== timer) return;
window.clearTimeout(timer);
autoUnlockTimerRef.current = null;
setIsAutoPromptPending(false);
};
}, [
locked,
autoPromptSystemUnlock,
reason,
reopenSignal,
windowInteractive,
onSystemUnlock,
systemUnlockStatus?.available,
systemUnlockStatus?.enabled,
systemUnlockStatus?.label,
passwordFallbackRequested,
systemUnlockError,
showReset,
isSystemUnlocking,
requestAutomaticSystemUnlock,
]);
if (!locked) return null;
const errorKey = getAppLockErrorMessageKey(error);
const SystemUnlockIcon = systemUnlockStatus?.platform === 'darwin' ? Fingerprint : KeyRound;
const isSystemButtonLoading = isAutoPromptPending || isSystemUnlocking;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
const result = await onUnlock(password);
if (!result.ok) {
setError(result.error);
setIsSubmitting(false);
return;
}
setPassword('');
setIsSubmitting(false);
};
const handleLogoClick = () => {
const now = Date.now();
setLogoClickCount((current) => {
const isQuickSequence = lastLogoClickAt !== null && now - lastLogoClickAt <= RESET_REVEAL_WINDOW_MS;
const nextCount = isQuickSequence ? current + 1 : 1;
if (nextCount >= RESET_REVEAL_CLICK_COUNT) {
setShowReset(true);
}
return nextCount;
});
setLastLogoClickAt(now);
};
const handleReset = async () => {
if (isResetting) return;
setIsResetting(true);
setResetError(false);
try {
await onResetAppLock(password);
} catch {
setResetError(true);
setIsResetting(false);
}
};
return (
// Overlay covers the title bar, and is the only chrome on a locked first
// paint, so the backdrop owns the window drag region.
<div
ref={overlayRootRef}
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"
style={{ zIndex: 2147483647 }}
role="dialog"
data-state="open"
data-app-lock-overlay=""
aria-modal="true"
aria-labelledby="app-lock-title"
>
<form
className="flex w-full max-w-[360px] flex-col items-center gap-5 app-no-drag"
onSubmit={handleSubmit}
>
<button
type="button"
className="flex h-24 w-24 items-center justify-center border-0 bg-transparent p-0 shadow-none transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={t('appLock.logoLabel')}
data-testid="app-lock-logo-easter-egg"
onClick={handleLogoClick}
>
<AppLogo className="h-full w-full" />
</button>
<h1 id="app-lock-title" className="text-center text-xl font-semibold tracking-normal">
{t('appLock.title')}
</h1>
{showPasswordUnlock && (
<>
<div className="w-full space-y-2">
<Label htmlFor="app-lock-password" className="sr-only">
{t('appLock.passwordLabel')}
</Label>
<div className="relative">
<Input
ref={passwordRef}
id="app-lock-password"
type="password"
value={password}
autoComplete="current-password"
placeholder={t('appLock.passwordPlaceholder')}
disabled={isSubmitting}
aria-invalid={Boolean(errorKey)}
aria-describedby={errorKey ? 'app-lock-error' : undefined}
className="pr-12"
onChange={(event) => {
setPassword(event.target.value);
if (error) setError(null);
}}
/>
<span
className="pointer-events-none absolute inset-y-px right-px flex w-10 items-center justify-center rounded-r-md bg-muted/30 text-muted-foreground"
data-testid="app-lock-password-lock-icon"
aria-hidden="true"
>
<LockKeyhole className="h-4 w-4" strokeWidth={2.25} />
</span>
</div>
{errorKey && (
<p id="app-lock-error" className="text-sm text-destructive">
{t(errorKey)}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? t('appLock.unlocking') : t('appLock.unlock')}
</Button>
</>
)}
{canUseSystemUnlock && systemUnlockStatus?.label && (
<div className="w-full space-y-2">
<Button
type="button"
variant={showPasswordUnlock ? 'outline' : 'default'}
className="w-full"
disabled={isSystemButtonLoading}
data-testid="app-lock-system-unlock-button"
onClick={() => void handleSystemUnlock()}
>
{isSystemButtonLoading ? (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
data-testid="app-lock-system-unlock-loading"
aria-hidden="true"
/>
) : (
<SystemUnlockIcon
className="mr-2 h-4 w-4"
data-testid={systemUnlockStatus.platform === 'darwin'
? 'app-lock-system-unlock-touch-id-icon'
: 'app-lock-system-unlock-windows-icon'}
aria-hidden="true"
/>
)}
<span aria-live="polite">
{isAutoPromptPending
? t('appLock.systemUnlock.preparing').replace('{label}', systemUnlockStatus.label)
: isSystemUnlocking
? t('appLock.systemUnlock.verifying').replace('{label}', systemUnlockStatus.label)
: t('appLock.systemUnlock.unlockWith').replace('{label}', systemUnlockStatus.label)}
</span>
</Button>
{!showPasswordUnlock && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto w-full py-1 text-muted-foreground"
onClick={() => {
lastAutoUnlockPresentationRef.current = reason === 'background'
? `background:${reopenSignal}`
: `foreground:${reason ?? 'default'}:${locked}`;
clearAutoUnlockTimer();
setPasswordFallbackRequested(true);
setSystemUnlockError(false);
}}
>
{t('appLock.usePassword')}
</Button>
)}
{systemUnlockError && (
<p className="text-sm text-destructive">
{t('appLock.systemUnlock.error')}
</p>
)}
</div>
)}
{showReset && (
<div
className="w-full rounded-lg border border-border/70 bg-card p-3 text-left shadow-sm"
role="region"
aria-live="polite"
>
<div className="space-y-1">
<p className="text-sm font-medium">{t('appLock.reset.title')}</p>
<p className="text-xs leading-5 text-muted-foreground">
{t('appLock.reset.description')}
</p>
</div>
{resetError && (
<p className="mt-2 text-xs text-destructive">
{t('appLock.reset.error')}
</p>
)}
<div className="mt-3 flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
disabled={isResetting}
onClick={() => {
setShowReset(false);
setLogoClickCount(0);
setLastLogoClickAt(null);
setResetError(false);
}}
>
{t('appLock.reset.cancel')}
</Button>
<Button
type="button"
variant="destructive"
className="flex-1"
disabled={isResetting}
onClick={handleReset}
>
{isResetting ? t('appLock.reset.resetting') : t('appLock.reset.confirm')}
</Button>
</div>
</div>
)}
</form>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
function readComponentSource(path: string) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
test("settings, tray, and terminal popup routes do not create fallback settings owners", () => {
const settingsPage = readComponentSource("./SettingsPage.tsx");
const trayPanel = readComponentSource("./TrayPanel.tsx");
const terminalPopupPage = readComponentSource("./TerminalPopupPage.tsx");
assert.doesNotMatch(settingsPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(settingsPage, /settings\?:\s*SettingsState/);
assert.doesNotMatch(settingsPage, /providedSettings\s*\?\?/);
assert.doesNotMatch(trayPanel, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(trayPanel, /settings\?:\s*SettingsState/);
assert.doesNotMatch(trayPanel, /providedSettings\s*\?\?/);
assert.doesNotMatch(terminalPopupPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(terminalPopupPage, /settings\?:\s*SettingsState/);
assert.doesNotMatch(terminalPopupPage, /providedSettings\s*\?\?/);
});
test("terminal popups wait for app-lock initialization before starting a terminal", () => {
const indexSource = readComponentSource("../index.tsx");
assert.match(
indexSource,
/allowTerminalStart=\{appLock\.initialized\s*&&\s*!appLock\.locked\}/,
);
});
+18 -7
View File
@@ -4,7 +4,6 @@
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAISettingsState } from "../application/state/useAISettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
@@ -13,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import type { AppLockGateRenderContext } from "./AppLockGate";
import { toast } from "./ui/toast";
import { SettingsTabContent } from "./settings/settings-ui";
import { SettingsFocusProvider, useSettingsFocus } from "./settings/SettingsFocusContext";
@@ -64,7 +64,8 @@ class AITabErrorBoundary extends React.Component<
}
}
type SettingsState = ReturnType<typeof useSettingsState>;
type SettingsState = AppLockGateRenderContext["settings"];
type AppLockState = AppLockGateRenderContext["appLock"];
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
@@ -298,7 +299,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
);
};
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
@@ -606,6 +607,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
{mountedTabs.has("system") && (
<SettingsLazyTab value="system">
<LazySettingsSystemTab
appLockSettings={settings.appLockSettings}
setAppLockTimeoutMinutes={settings.setAppLockTimeoutMinutes}
requestAppLockDisable={settings.requestAppLockDisable}
requestAppLockPasswordChange={settings.requestAppLockPasswordChange}
appLockSystemUnlockStatus={appLock?.systemUnlockStatus}
setAppLockSystemUnlockEnabled={settings.setAppLockSystemUnlockEnabled}
sessionLogsEnabled={settings.sessionLogsEnabled}
setSessionLogsEnabled={settings.setSessionLogsEnabled}
sessionLogsDir={settings.sessionLogsDir}
@@ -662,13 +669,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
);
};
export default function SettingsPage() {
const settings = useSettingsState();
export default function SettingsPage({
settings,
appLock,
}: {
settings: SettingsState;
appLock: AppLockState;
}) {
return (
<I18nProvider locale={settings.uiLanguage}>
<SettingsFocusProvider>
<SettingsPageContent settings={settings} />
<SettingsPageContent settings={settings} appLock={appLock} />
</SettingsFocusProvider>
</I18nProvider>
);
+5
View File
@@ -53,6 +53,7 @@ import {
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
import { isAppLockOverlayActive } from '../infrastructure/appLockOverlayDom';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
clearVaultDropIndicator as clearSnippetDropIndicator,
@@ -622,6 +623,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
if (!isRecordingShortkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
// App lock overlay installs later; skip while locked so password keys
// never mutate shortkeys (Codex P2).
if (isAppLockOverlayActive()) return;
e.preventDefault();
e.stopPropagation();
@@ -774,6 +778,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
if (rightPanelMode !== 'edit-snippet') return;
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return;
event.preventDefault();
+38 -4
View File
@@ -120,6 +120,8 @@ import {
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
@@ -605,6 +607,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const isFocusedRef = useRef(!!isFocused);
isFocusedRef.current = !!isFocused;
const oscNotificationScannerRef = useRef(new OscNotificationStreamScanner());
const hibernateEnabled =
resolveTerminalHibernateEnabledForProtocol(terminalSettings, effectiveTerminalProtocol) &&
!kittyKeyboardProtocolEnabledForSession &&
@@ -1001,6 +1006,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onBroadcastInputRef.current(text, sessionId);
}
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
// Walking printable bytes would append "." and leave history/completions
// tracking a stale line.
if (text.startsWith("\x1b")) {
return;
}
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (handledSubmittedInput) {
@@ -1899,15 +1911,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
flushTerminalSessionFlowAck(backendId);
terminalBackend.setSessionFlowPaused?.(backendId, false);
hibernatePendingBufferRef.current = "";
oscNotificationScannerRef.current = new OscNotificationStreamScanner();
disposeDataRef.current = terminalBackend.onSessionData(
backendId,
(chunk, meta) => {
observeTerminalInputPrompt(chunk, meta);
const scanned = oscNotificationScannerRef.current.consume(chunk);
for (const notification of scanned.notifications) {
handleTerminalOscNotification({
notification,
mode: terminalSettingsRef.current?.oscNotifications,
sessionFocused: isFocusedRef.current,
sessionId,
fallbackTitle: host.label || host.hostname || "Netcatty",
onSessionActivity: () => onTerminalBell?.(sessionId),
});
}
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
? hibernatePendingBufferRef.current + chunk
? hibernatePendingBufferRef.current + scanned.remainder
: appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
chunk,
scanned.remainder,
);
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
@@ -1937,7 +1961,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSessionExitRef.current?.(sessionId, evt);
scheduleAutoReconnect({ evt });
});
}, [observeTerminalInputPrompt, scheduleAutoReconnect, sessionId, terminalBackend]);
}, [host.hostname, host.label, observeTerminalInputPrompt, onTerminalBell, scheduleAutoReconnect, sessionId, terminalBackend]);
const clearHibernateRetry = useCallback(() => {
if (hibernateRetryTimerRef.current === null) return;
@@ -3920,6 +3944,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onBell: () => {
onTerminalBell?.(sessionId);
},
onOscNotification: (notification) => {
handleTerminalOscNotification({
notification,
mode: terminalSettingsRef.current?.oscNotifications,
sessionFocused: isFocusedRef.current,
sessionId,
fallbackTitle: host.label || host.hostname || "Netcatty",
onSessionActivity: () => onTerminalBell?.(sessionId),
});
},
onOsc52ReadRequest: handleOsc52ReadRequest,
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
@@ -4035,7 +4069,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return false;
},
takePendingBuffer: () => {
const pending = hibernatePendingBufferRef.current;
const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush();
hibernatePendingBufferRef.current = "";
return pending;
},
+1 -1
View File
@@ -149,7 +149,7 @@ test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
source,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner \/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
);
});
+32 -10
View File
@@ -1,7 +1,6 @@
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
import { useSettingsState } from '../application/state/useSettingsState';
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
@@ -18,6 +17,7 @@ import type { KnownHost } from '../types';
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
import { detectLocalOs } from '../lib/localShell';
import { cn } from '../lib/utils';
import type { AppLockGateRenderContext } from './AppLockGate';
import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost';
const Terminal = lazy(() => import('./Terminal'));
@@ -28,6 +28,7 @@ const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500;
const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000;
type PopupThemeVars = React.CSSProperties & Record<string, string>;
type SettingsState = AppLockGateRenderContext["settings"];
const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => {
const { colors } = theme;
@@ -250,7 +251,14 @@ export function resolveTerminalPopupReuseId(config: TerminalPopupPayload): strin
return config.sourceSession.reuseConnectionFromSessionId;
}
function TerminalPopupPageInner() {
function TerminalPopupPageInner({
settings,
allowTerminalStart = true,
}: {
settings: SettingsState;
/** When false (app locked), keep config listeners mounted but do not start Terminal. */
allowTerminalStart?: boolean;
}) {
const { t } = useI18n();
const {
close,
@@ -260,7 +268,6 @@ function TerminalPopupPageInner() {
setWindowTitle,
} = useTerminalPopupWindow();
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
const settings = useSettingsState();
const {
isInitialized: vaultInitialized,
hosts,
@@ -338,9 +345,8 @@ function TerminalPopupPageInner() {
void setWindowTitle(payload.title);
}
});
// Main delivers the popup payload as soon as the renderer reports ready
// (and destroys the window if it never does) — so report ready only after
// the config listener above is registered.
// Popup config may already be buffered by preload, so register the listener
// before reporting renderer readiness or the buffered payload can be lost.
notifyRendererReady();
return unsubscribe;
}, [notifyRendererReady, onPopupConfig, setWindowTitle]);
@@ -379,7 +385,15 @@ function TerminalPopupPageInner() {
return resolveTerminalPopupReuseId(config);
}, [config]);
const ready = Boolean(config && host && vaultInitialized);
// Defer *first* backend start while app-locked so startup commands/credentials
// do not run behind the overlay. Once a session has started, keep it mounted
// across re-locks so the popup is not torn down mid-session (Codex P2).
const readyToStart = Boolean(config && host && vaultInitialized && allowTerminalStart);
const [sessionStarted, setSessionStarted] = useState(false);
useEffect(() => {
if (readyToStart) setSessionStarted(true);
}, [readyToStart]);
const ready = sessionStarted || readyToStart;
const startupRevealDelayMs = useMemo(() => {
// Attach mode shows the live session immediately (no startup command).
if (isAttachMode) return 0;
@@ -516,11 +530,19 @@ function TerminalPopupPageInner() {
);
}
export default function TerminalPopupPage() {
const settings = useSettingsState();
export default function TerminalPopupPage({
settings,
allowTerminalStart = true,
}: {
settings: SettingsState;
allowTerminalStart?: boolean;
}) {
return (
<I18nProvider locale={settings.uiLanguage}>
<TerminalPopupPageInner />
<TerminalPopupPageInner
settings={settings}
allowTerminalStart={allowTerminalStart}
/>
<PluginAuthenticationHost />
</I18nProvider>
);
+24 -1
View File
@@ -1,4 +1,4 @@
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
import { Folder, FolderLock, Lock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
@@ -147,6 +147,8 @@ interface TopTabsProps {
onOpenQuickSwitcher: () => void;
onThemeChange: (theme: 'dark' | 'light' | 'system') => void;
onOpenSettings: () => void;
onLockApp?: () => void;
appLockEnabled?: boolean;
externalMcpEnabled: boolean;
onToggleExternalMcp: (enabled: boolean) => void;
showExternalMcpToggle?: boolean;
@@ -196,6 +198,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onOpenQuickSwitcher,
onThemeChange,
onOpenSettings,
onLockApp,
appLockEnabled,
externalMcpEnabled,
onToggleExternalMcp,
showExternalMcpToggle = true,
@@ -1198,6 +1202,22 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
className="h-7 w-7 shrink-0 top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{appLockEnabled && onLockApp && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onLockApp}
>
<Lock size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.lockApp')}</TooltipContent>
</Tooltip>
)}
<TopTabsQuickControls
theme={theme}
themePreference={themePreference}
@@ -1209,6 +1229,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setWindowOpacity={setWindowOpacity}
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1259,6 +1280,8 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onLockApp === next.onLockApp &&
prev.appLockEnabled === next.appLockEnabled &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
prev.onToggleExternalMcp === next.onToggleExternalMcp &&
prev.showExternalMcpToggle === next.showExternalMcpToggle &&
@@ -4,6 +4,14 @@ import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./TrayPanel.tsx", import.meta.url), "utf8");
test("tray panel does not paint a CSS drop shadow on the host card", () => {
// BrowserWindow already has hasShadow. CSS shadow-lg on a transparent
// macOS overlay double-composites as an extra outline under the card.
const rootOpenTag = source.match(/<div id="tray-panel-root"[^>]*>/);
assert.ok(rootOpenTag, "expected tray-panel-root");
assert.doesNotMatch(rootOpenTag[0], /shadow-lg|shadow-md|shadow-xl/);
});
test("tray session close actions are discoverable without mouse hover", () => {
const keyboardVisible = source.match(/focus-visible:opacity-100/g) ?? [];
const touchVisible = source.match(/\[@media\(hover:none\)\]:opacity-100/g) ?? [];
+5 -4
View File
@@ -8,7 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
@@ -19,6 +18,7 @@ import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
import type { AppLockGateRenderContext } from "./AppLockGate";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
status,
@@ -270,7 +270,7 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
return (
<>
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
@@ -514,8 +514,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
);
};
const TrayPanel: React.FC = () => {
const settings = useSettingsState();
type SettingsState = AppLockGateRenderContext["settings"];
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent terminalSettings={settings.terminalSettings} />
+103 -1
View File
@@ -154,7 +154,7 @@ test('renders separate steer and stop actions for a running Codex App Server tur
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
assert.match(html, /placeholder="ai\.codex\.steer\.placeholder"/);
assert.match(html, /aria-label="Stop"/);
assert.match(html, /disabled=""[^>]*aria-label="Select model"/);
assert.match(html, /disabled=""[^>]*aria-label="ai\.chat\.selectModel"/);
});
test('allows terminal-selection-only steering submissions', () => {
@@ -222,6 +222,108 @@ test('slash picker system commands clear the local composer', () => {
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
});
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Codex"
modelPresets={[{
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: ['low', 'medium', 'high'],
}]}
selectedModelId="gpt-5.5/high"
onModelSelect={() => {}}
/>
</TooltipProvider>,
);
assert.match(html, /aria-label="ai\.chat\.selectModel"/);
assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/);
assert.match(html, />High</);
assert.doesNotMatch(html, /GPT-5\.5 \/ High/);
});
test('Catty composer exposes a provider switcher without a mixed thinking submenu', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /ComposerModelPicker/);
assert.match(source, /ComposerThinkingChip/);
assert.match(source, /cattyReasoningLevelsForSelection/);
assert.match(source, /if \(!thinkingLevel\) return;/);
assert.doesNotMatch(source, /showThinkingLevels/);
});
test('Catty thinking chip is hidden for OpenAI models that reject reasoningEffort', () => {
const openai = {
id: 'p1',
providerId: 'openai' as const,
name: 'OpenAI',
enabled: true,
};
const gpt4o = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-4o',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/);
const gpt51Chat = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-5.1-chat-latest',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/);
const gpt55 = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-5.5',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.match(gpt55, /aria-label="ai\.chat\.thinkingLevel"/);
});
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /getSystemSlashCommand/);
+178 -181
View File
@@ -7,6 +7,27 @@
*/
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
resolveModelSelectionWithThinking,
resolveThinkingSelection,
type ComposerModelPrefs,
} from '../../infrastructure/ai/composerPicker';
import {
cattyReasoningLevelsForSelection,
resolveVisibleCattyThinkingLevel,
} from '../../infrastructure/ai/cattyReasoning';
import {
readComposerModelPrefs,
rememberComposerRecentModel,
subscribeComposerModelPrefs,
toggleComposerPinnedModel,
} from '../../infrastructure/ai/composerModelPrefs';
import {
ComposerModelPicker,
COMPOSER_MODEL_PICKER_WIDTH,
COMPOSER_PROVIDER_PICKER_WIDTH,
} from './ComposerModelPicker';
import { ComposerThinkingChip } from './ComposerThinkingChip';
import {
buildSlashCommandItems,
filterQuickMessages,
@@ -32,11 +53,11 @@ import {
PromptInputTools,
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
import { markAiComposerActivity, setAiComposerComposing } from './aiMarkdownWarmup';
import {
@@ -51,12 +72,8 @@ import {
resolveVisibleChatInputMaxHeight,
} from './chatInputResize';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
// Slightly wider for the provider picker so the per-row default-model
// caption doesn't truncate.
const PROVIDER_PICKER_MAX_WIDTH = 320;
const PERMISSION_PICKER_WIDTH = 250;
const PERMISSION_PICKER_WIDTH = 200;
const THINKING_PICKER_WIDTH = 168;
const MENU_VIEWPORT_GUTTER = 8;
const CONTEXT_USAGE_RING_RADIUS = 10;
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
@@ -69,11 +86,8 @@ function formatContextTokens(tokens: number): string {
/**
* Provider picker payload used by Catty Agent. When set, the model chip
* switches to a flat provider list (provider icon + name + the provider's
* configured default model as caption) in place of the generic Cpu glyph
* + model-preset dropdown. Each provider exposes a single model — its
* `defaultModel` — so a two-level menu would be empty noise; picking a
* provider implicitly picks its model.
* opens a single-column model list. The current provider sits on a
* one-line header that drills into a second-level provider list.
*/
export interface ProviderSwitcherConfig {
/** Every configured provider — Settings-level visibility, not the
@@ -85,7 +99,7 @@ export interface ProviderSwitcherConfig {
/** Currently bound model id under the selected provider. */
selectedModelId?: string;
/** Fires when the user picks a (providerId, modelId) pair. */
onSelect: (providerId: string, modelId: string) => void;
onSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
}
type ComposerHasTextStore = {
@@ -223,6 +237,11 @@ interface ChatInputProps {
* `modelPresets` dropdown because their provider is wired inside the CLI.
*/
providerSwitcher?: ProviderSwitcherConfig;
/** Scope key for recent/pinned model prefs. */
pickerScope?: string;
/** Catty-only reasoning effort, stored separately from the model id. */
thinkingLevel?: string;
onThinkingLevelChange?: (level: string) => void;
/** Hidden retained panels must not leave body-portaled menus open. */
parked?: boolean;
}
@@ -260,6 +279,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
permissionMode,
onPermissionModeChange,
providerSwitcher,
pickerScope = 'default',
thinkingLevel,
onThinkingLevelChange,
parked = false,
}) => {
const { t } = useI18n();
@@ -276,11 +298,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Active highlight index for @ mention / slash skill keyboard navigation
@@ -288,6 +310,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showThinkingPicker = activeMenu === 'thinking';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
@@ -297,11 +320,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setSlashQuery('');
setSlashRange(null);
}, []);
useEffect(() => {
const refresh = () => setModelPrefs(readComposerModelPrefs(pickerScope));
refresh();
return subscribeComposerModelPrefs(refresh);
}, [pickerScope]);
useEffect(() => {
if (parked) closeAllMenus();
}, [closeAllMenus, parked]);
@@ -585,8 +613,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
);
useEffect(() => {
if (lockTurnConfiguration && (showModelPicker || showPermPicker)) closeAllMenus();
}, [closeAllMenus, lockTurnConfiguration, showModelPicker, showPermPicker]);
if (lockTurnConfiguration && (showModelPicker || showThinkingPicker || showPermPicker)) closeAllMenus();
}, [closeAllMenus, lockTurnConfiguration, showModelPicker, showThinkingPicker, showPermPicker]);
const quickMessageSlugSet = useMemo(
() => new Set(quickMessages.map((message) => message.slug)),
@@ -761,7 +789,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
useEffect(() => {
if (!showSlashCommandPicker || !menuPos) return;
const onKeyDown = (event: KeyboardEvent) => handleSlashCommandKeyDown(event);
const onKeyDown = (event: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
handleSlashCommandKeyDown(event);
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [handleSlashCommandKeyDown, menuPos, showSlashCommandPicker]);
@@ -863,21 +894,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
// 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 { preset: selectedPreset, thinking: selectedPresetThinking } = resolveThinkingSelection(
selectedModelId,
modelPresets,
);
const selectedBaseModelId = selectedPreset?.id;
// Provider switcher mode (Catty Agent): two-column popover, chip carries
// Provider switcher mode (Catty Agent): single-column popover, chip carries
// the provider's icon + name + model name. Falls back to the existing
// single-list model dropdown for external SDK agents.
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
@@ -897,13 +919,54 @@ const ChatInput: React.FC<ChatInputProps> = ({
: '';
const modelLabel = hasProviderSwitcher
? providerSwitcherChipLabel
: (selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel'));
const modelChipMaxWidth = hasProviderSwitcher
? 'max-w-[180px]'
: (selectedThinking ? 'max-w-[148px]' : 'max-w-[82px]');
: (selectedPreset?.name || modelName || providerName || t('ai.chat.noModel'));
const modelChipMaxWidth = hasProviderSwitcher ? 'max-w-[168px]' : 'max-w-[96px]';
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
const thinkingLevels = useMemo(
() => (
hasProviderSwitcher && onThinkingLevelChange
? cattyReasoningLevelsForSelection(
selectedSwitcherProvider,
providerSwitcher?.selectedModelId,
)
: (selectedPreset?.thinkingLevels ?? [])
),
[
hasProviderSwitcher,
onThinkingLevelChange,
selectedSwitcherProvider,
providerSwitcher?.selectedModelId,
selectedPreset?.thinkingLevels,
],
);
const selectedThinking = hasProviderSwitcher
? thinkingLevel
: selectedPresetThinking;
const visibleThinking = hasProviderSwitcher
? (selectedThinking
? resolveVisibleCattyThinkingLevel(thinkingLevels, selectedThinking)
: undefined)
: selectedThinking;
useEffect(() => {
if (!hasProviderSwitcher || !onThinkingLevelChange) return;
if (!thinkingLevels.length) return;
if (!thinkingLevel) return;
if (thinkingLevels.includes(thinkingLevel)) return;
const next = resolveVisibleCattyThinkingLevel(thinkingLevels, thinkingLevel);
if (next && next !== thinkingLevel) onThinkingLevelChange(next);
}, [
hasProviderSwitcher,
onThinkingLevelChange,
providerSwitcher?.selectedProviderId,
providerSwitcher?.selectedModelId,
thinkingLevel,
thinkingLevels,
]);
const showThinkingChip = thinkingLevels.length > 0 && (!hasProviderSwitcher || !!onThinkingLevelChange);
const popoverMaxWidth = hasProviderSwitcher
? COMPOSER_PROVIDER_PICKER_WIDTH
: COMPOSER_MODEL_PICKER_WIDTH;
const contextUsagePercent = contextUsage
? Math.min(100, Math.max(0, (contextUsage.inputTokens / contextUsage.contextWindow) * 100))
: 0;
@@ -919,7 +982,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
.replace('{used}', formatContextTokens(contextUsage.inputTokens))
.replace('{max}', formatContextTokens(contextUsage.contextWindow))
: '';
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
@@ -1289,9 +1351,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
if (selectedPreset?.thinkingLevels?.length) {
setHoveredModelId(selectedPreset.id);
}
setActiveMenu('model');
} else {
closeAllMenus();
@@ -1299,7 +1358,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
}}
disabled={lockTurnConfiguration}
className={`${chipClassName} min-w-0 ${hasModelPicker && !lockTurnConfiguration ? 'cursor-pointer hover:bg-muted/24 transition-colors' : 'opacity-60'}`}
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
aria-label={hasProviderSwitcher ? t('ai.chat.selectProviderAndModel') : t('ai.chat.selectModel')}
aria-expanded={showModelPicker}
>
{hasProviderSwitcher && selectedSwitcherProvider ? (
@@ -1353,144 +1412,79 @@ const ChatInput: React.FC<ChatInputProps> = ({
</Tooltip>
)}
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label={hasProviderSwitcher ? t('ai.chat.selectProviderAndModel') : t('ai.chat.selectModel')}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
onMouseLeave={() => setHoveredModelId(null)}
>
{hasProviderSwitcher ? (
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
{providerSwitcher!.providers.map((p) => {
const isSelected = providerSwitcher!.selectedProviderId === p.id;
const defaultModel = p.defaultModel?.trim() ?? '';
const hasModel = defaultModel.length > 0;
// Rows without a defaultModel are inert — picking
// one would save a binding with an empty model id
// and produce a confusing model error at send time.
// User has to set a defaultModel in Settings first.
const disabled = !hasModel;
const modelCaption = hasModel
? defaultModel
: t('ai.chat.noProviderModel');
return (
<button
key={p.id}
type="button"
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
disabled={disabled}
title={disabled ? t('ai.chat.noProviderModel') : undefined}
onClick={() => {
if (disabled) return;
providerSwitcher!.onSelect(p.id, defaultModel);
closeAllMenus();
}}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
disabled
? 'opacity-55 cursor-not-allowed'
: 'hover:bg-muted/30 cursor-pointer'
}`}
>
<ProviderIconBadge provider={p} size="md" />
<div className="flex-1 min-w-0">
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
{modelCaption}
</div>
</div>
{isSelected && <Check size={12} className="text-primary shrink-0" />}
</button>
);
})}
</div>
) : (
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
{modelPresets.map(preset => {
const isSelected = preset.id === selectedBaseModelId;
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
const showThinkingLevels = hasThinking && hoveredModelId === preset.id;
return (
<div
key={preset.id}
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
>
<button
type="button"
role="option"
aria-selected={isSelected}
aria-expanded={hasThinking ? showThinkingLevels : undefined}
onClick={() => {
if (!hasThinking) {
onModelSelect?.(preset.id);
closeAllMenus();
return;
}
setHoveredModelId(showThinkingLevels ? null : preset.id);
}}
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 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && (
<ChevronRight
size={10}
className={`text-muted-foreground/50 shrink-0 transition-transform ${showThinkingLevels ? 'rotate-90' : ''}`}
/>
)}
</button>
{/* Inline thinking levels — flyout submenus get clipped by overflow-y-auto above. */}
{showThinkingLevels && (
<div role="listbox" aria-label="Thinking level" className="border-t border-border/30 bg-muted/10 py-0.5">
{preset.thinkingLevels!.map(level => {
const fullId = `${preset.id}/${level}`;
const isLevelSelected = selectedModelId === fullId;
return (
<button
key={level}
type="button"
role="option"
aria-selected={isLevelSelected}
tabIndex={0}
onClick={() => {
onModelSelect?.(fullId);
closeAllMenus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onModelSelect?.(fullId);
closeAllMenus();
} else if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 pl-7 pr-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
<ComposerModelPicker
providers={hasProviderSwitcher ? providerSwitcher!.providers : undefined}
selectedProviderId={providerSwitcher?.selectedProviderId}
selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId}
modelPresets={hasProviderSwitcher ? undefined : modelPresets}
prefs={modelPrefs}
onSelectProviderModel={(providerId, modelId, contextWindow) => {
providerSwitcher?.onSelect(providerId, modelId, contextWindow);
setModelPrefs(rememberComposerRecentModel(pickerScope, { providerId, modelId }));
closeAllMenus();
}}
onSelectModel={(modelId) => {
const preset = modelPresets.find((item) => item.id === modelId);
const nextId = preset
? resolveModelSelectionWithThinking(preset, selectedPresetThinking)
: modelId;
onModelSelect?.(nextId);
setModelPrefs(rememberComposerRecentModel(pickerScope, { modelId }));
closeAllMenus();
}}
onTogglePinned={(entry) => {
setModelPrefs(toggleComposerPinnedModel(pickerScope, entry));
}}
/>
</div>
</>,
document.body,
)}
{showThinkingChip && (
<ComposerThinkingChip
levels={thinkingLevels}
selectedLevel={visibleThinking}
disabled={lockTurnConfiguration}
open={showThinkingPicker}
menuPos={showThinkingPicker ? menuPos : null}
onToggle={(rect) => {
if (lockTurnConfiguration) return;
if (!showThinkingPicker) {
if (rect) {
const left = Math.max(
MENU_VIEWPORT_GUTTER,
Math.min(rect.left, window.innerWidth - THINKING_PICKER_WIDTH - MENU_VIEWPORT_GUTTER),
);
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('thinking');
} else {
closeAllMenus();
}
}}
onSelect={(level) => {
if (hasProviderSwitcher) {
onThinkingLevelChange?.(level);
} else if (selectedPreset) {
onModelSelect?.(
level && selectedPreset.thinkingLevels?.includes(level)
? `${selectedPreset.id}/${level}`
: selectedPreset.id,
);
}
closeAllMenus();
}}
onClose={closeAllMenus}
/>
)}
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
@@ -1543,7 +1537,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Permission mode"
className="fixed z-[1000] w-[250px] max-w-[calc(100vw-16px)] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
className="fixed z-[1000] w-[200px] max-w-[calc(100vw-16px)] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{([
@@ -1561,16 +1555,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
onPermissionModeChange(mode);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{permissionMode === mode
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />
}
<Icon size={12} className={`${color} shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-foreground/85">{label}</div>
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
<div className="flex-1 min-w-0 flex items-baseline gap-1.5">
<span className="text-foreground/85 shrink-0">{label}</span>
<span className="text-[11px] text-muted-foreground/45 truncate">{desc}</span>
</div>
</button>
))}
@@ -1654,6 +1648,9 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.permissionMode === next.permissionMode
&& prev.onPermissionModeChange === next.onPermissionModeChange
&& prev.providerSwitcher === next.providerSwitcher
&& prev.pickerScope === next.pickerScope
&& prev.thinkingLevel === next.thinkingLevel
&& prev.onThinkingLevelChange === next.onThinkingLevelChange
&& prev.parked === next.parked
);
}
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ComposerModelPicker } from './ComposerModelPicker';
test('Catty picker keeps a single model list and hides other providers until the submenu opens', () => {
const html = renderToStaticMarkup(
<ComposerModelPicker
providers={[
{
id: 'p1',
providerId: 'deepseek',
name: 'DeepSeek',
defaultModel: 'deepseek-v4-pro',
enabled: true,
},
{
id: 'p2',
providerId: 'openai',
name: 'OpenAI',
defaultModel: 'gpt-5.5',
enabled: true,
},
]}
selectedProviderId="p1"
selectedModelId="deepseek-v4-pro"
prefs={{ recent: [], pinned: [] }}
onSelectProviderModel={() => {}}
onTogglePinned={() => {}}
/>,
);
assert.match(html, /DeepSeek/);
assert.match(html, /placeholder="ai\.chat\.searchModels"/);
assert.match(html, /deepseek-v4-pro/);
assert.match(html, /aria-label="ai\.chat\.selectProvider"/);
assert.doesNotMatch(html, /OpenAI/);
assert.doesNotMatch(html, /w-\[128px\]/);
});
test('external agent picker lists presets without a provider column', () => {
const html = renderToStaticMarkup(
<ComposerModelPicker
modelPresets={[
{ id: 'gpt-5.5', name: 'GPT-5.5' },
{ id: 'gpt-5.4', name: 'GPT-5.4' },
]}
selectedModelId="gpt-5.5"
prefs={{ recent: [{ modelId: 'gpt-5.5' }], pinned: [] }}
onSelectModel={() => {}}
onTogglePinned={() => {}}
/>,
);
assert.match(html, /GPT-5\.5/);
assert.match(html, /GPT-5\.4/);
assert.match(html, /ai\.chat\.recent/);
assert.doesNotMatch(html, /ai\.chat\.providers/);
});
test('custom model action is only offered in Catty provider-switcher mode', () => {
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
assert.match(source, /const showCustom = Boolean\(\s*hasProviders/s);
assert.match(source, /resolveComposerEnterModelId/);
});
+321
View File
@@ -0,0 +1,321 @@
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
filterComposerModels,
resolveComposerEnterModelId,
resolvePinnedAndRecentModels,
type ComposerModelPrefEntry,
type ComposerModelPrefs,
type ComposerPickerModel,
} from '../../infrastructure/ai/composerPicker';
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { useProviderModelCatalog } from './useProviderModelCatalog';
export const COMPOSER_PROVIDER_PICKER_WIDTH = 260;
export const COMPOSER_MODEL_PICKER_WIDTH = 260;
export interface ComposerModelPickerProps {
providers?: ProviderConfig[];
selectedProviderId?: string;
selectedModelId?: string;
modelPresets?: AgentModelPreset[];
prefs: ComposerModelPrefs;
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
onSelectModel?: (modelId: string) => void;
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
}
const rowClassName =
'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer';
const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] tracking-wide text-muted-foreground/45">
{children}
</div>
);
const ModelRow: React.FC<{
model: ComposerPickerModel;
selected: boolean;
pinned: boolean;
onSelect: () => void;
onTogglePinned: () => void;
pinLabel: string;
unpinLabel: string;
}> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
<div className="group/row relative">
<button
type="button"
role="option"
aria-selected={selected}
onClick={onSelect}
className={rowClassName}
>
{selected
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />}
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
</button>
<button
type="button"
aria-label={pinned ? unpinLabel : pinLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onTogglePinned();
}}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 transition-opacity ${
pinned
? 'text-amber-400/90 opacity-100'
: 'text-muted-foreground/45 opacity-0 group-hover/row:opacity-100 hover:text-foreground/70'
}`}
>
<Star size={11} fill={pinned ? 'currentColor' : 'none'} />
</button>
</div>
);
export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
providers = [],
selectedProviderId,
selectedModelId,
modelPresets = [],
prefs,
onSelectProviderModel,
onSelectModel,
onTogglePinned,
}) => {
const { t } = useI18n();
const hasProviders = providers.length > 0;
const [previewProviderId, setPreviewProviderId] = useState(
selectedProviderId || providers[0]?.id || '',
);
const [query, setQuery] = useState('');
const [view, setView] = useState<'models' | 'providers'>('models');
useEffect(() => {
if (selectedProviderId) setPreviewProviderId(selectedProviderId);
}, [selectedProviderId]);
const previewProvider = hasProviders
? providers.find((provider) => provider.id === previewProviderId) ?? providers[0]
: undefined;
const catalog = useProviderModelCatalog(previewProvider, hasProviders);
const models = useMemo<ComposerPickerModel[]>(() => {
if (hasProviders) return catalog.models;
return modelPresets.map((preset) => ({
id: preset.id,
name: preset.name,
description: preset.description,
}));
}, [catalog.models, hasProviders, modelPresets]);
const filtered = useMemo(() => filterComposerModels(models, query), [models, query]);
const grouped = useMemo(
() => resolvePinnedAndRecentModels({
models: filtered,
prefs,
providerId: previewProvider?.id,
allowMissing: Boolean(previewProvider?.id) && !query.trim(),
}),
[filtered, prefs, previewProvider?.id, query],
);
const pinnedKeys = useMemo(
() => new Set(
prefs.pinned
.filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id)
.map((entry) => entry.modelId),
),
[prefs.pinned, previewProvider],
);
const trimmedQuery = query.trim();
const showCustom = Boolean(
hasProviders
&& trimmedQuery
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
);
const selectModel = (modelId: string) => {
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
if (hasProviders && previewProvider) {
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
return;
}
onSelectModel?.(modelId);
};
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
);
if (hasProviders && view === 'providers') {
return (
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
<button
type="button"
onClick={() => setView('models')}
className={rowClassName}
>
<ChevronLeft size={12} className="text-muted-foreground/60 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/70">
{t('ai.chat.providers')}
</span>
</button>
<div className="mx-2 my-1 border-t border-border/40" />
{providers.map((provider) => {
const isBound = provider.id === selectedProviderId;
return (
<button
key={provider.id}
type="button"
role="option"
aria-selected={isBound}
onClick={() => {
setPreviewProviderId(provider.id);
setQuery('');
setView('models');
}}
className={rowClassName}
>
<ProviderIconBadge provider={provider} size="xs" />
<span className="min-w-0 flex-1 truncate text-foreground/88">{provider.name}</span>
{isBound && <Check size={11} className="text-primary shrink-0" />}
</button>
);
})}
</div>
);
}
return (
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
{hasProviders && previewProvider && (
<>
<button
type="button"
aria-label={t('ai.chat.selectProvider')}
onClick={() => setView('providers')}
className={rowClassName}
>
<ProviderIconBadge provider={previewProvider} size="xs" />
<span className="min-w-0 flex-1 truncate text-foreground/88">{previewProvider.name}</span>
<ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
</button>
</>
)}
<div className="px-2 pb-1">
<div className="flex h-7 items-center gap-1.5 rounded-md bg-muted/40 px-2">
<Search size={11} className="text-muted-foreground/50 shrink-0" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && trimmedQuery) {
event.preventDefault();
const nextId = resolveComposerEnterModelId({
query: trimmedQuery,
models,
grouped,
filtered,
showCustom,
});
if (nextId) selectModel(nextId);
}
}}
placeholder={t('ai.chat.searchModels')}
className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40"
/>
</div>
</div>
<div className="max-h-[280px] overflow-y-auto">
{catalog.loading && (
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
<Loader2 size={11} className="animate-spin" />
{t('ai.chat.loadingModels')}
</div>
)}
{showCustom && (
<button
type="button"
onClick={() => selectModel(trimmedQuery)}
className={rowClassName}
>
<Pin size={11} className="text-muted-foreground/55 shrink-0" />
<span className="min-w-0 truncate text-foreground/85">
{t('ai.chat.useCustomModel').replace('{id}', trimmedQuery)}
</span>
</button>
)}
{grouped.pinned.length > 0 && (
<>
<SectionLabel>{t('ai.chat.pinned')}</SectionLabel>
{grouped.pinned.map((model) => (
<ModelRow
key={`pin-${model.id}`}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
</>
)}
{grouped.recent.length > 0 && (
<>
<SectionLabel>{t('ai.chat.recent')}</SectionLabel>
{grouped.recent.map((model) => (
<ModelRow
key={`recent-${model.id}`}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned={pinnedKeys.has(model.id)}
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
</>
)}
{(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && (
<SectionLabel>{t('ai.chat.models')}</SectionLabel>
)}
{grouped.rest.map((model) => (
<ModelRow
key={model.id}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned={pinnedKeys.has(model.id)}
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
{!catalog.loading && filtered.length === 0 && !showCustom && (
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
{catalog.error || t('ai.chat.noMatchingModels')}
</div>
)}
</div>
</div>
);
};
export default React.memo(ComposerModelPicker);
+104
View File
@@ -0,0 +1,104 @@
import { Brain, Check, ChevronDown } from 'lucide-react';
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../application/i18n/I18nProvider';
import { formatComposerThinkingLabel } from '../../infrastructure/ai/composerPicker';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface ComposerThinkingChipProps {
levels: readonly string[];
selectedLevel?: string;
disabled?: boolean;
open: boolean;
menuPos: { left: number; bottom: number } | null;
onToggle: (rect: DOMRect | undefined) => void;
onSelect: (level: string) => void;
onClose: () => void;
}
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
levels,
selectedLevel,
disabled = false,
open,
menuPos,
onToggle,
onSelect,
onClose,
}) => {
const { t } = useI18n();
const btnRef = useRef<HTMLButtonElement>(null);
const formatLevel = (level: string) => (
level === 'off' ? t('ai.chat.thinkingOff') : formatComposerThinkingLabel(level)
);
const label = selectedLevel
? formatLevel(selectedLevel)
: t('ai.chat.thinkingLevel');
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={btnRef}
type="button"
disabled={disabled}
onClick={() => onToggle(btnRef.current?.getBoundingClientRect())}
className={`${chipClassName} shrink-0 ${
disabled
? 'opacity-60'
: 'cursor-pointer hover:bg-muted/24 transition-colors'
}`}
aria-label={t('ai.chat.thinkingLevel')}
aria-expanded={open}
>
<Brain size={11} className="text-violet-400/75" />
<span className="truncate max-w-[56px]">{label}</span>
{!disabled && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.thinkingLevel')}</TooltipContent>
</Tooltip>
{open && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={onClose} />
<div
role="listbox"
aria-label={t('ai.chat.thinkingLevel')}
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{levels.map((level) => {
const isSelected = selectedLevel === level;
return (
<button
key={level}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
if (!isSelected) onSelect(level);
else onClose();
}}
className="w-full flex items-center gap-2 px-2.5 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" />}
<Brain size={12} className="text-violet-400/70 shrink-0" />
<span className="text-foreground/85">{formatLevel(level)}</span>
</button>
);
})}
</div>
</>,
document.body,
)}
</>
);
};
export default React.memo(ComposerThinkingChip);
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
fetchProviderModelCatalog,
providerModelCacheKey,
readCachedProviderModelCatalog,
seedProviderModelCatalog,
} from '../../infrastructure/ai/cattyProviderModels';
import type { ComposerPickerModel } from '../../infrastructure/ai/composerPicker';
import type { ProviderConfig } from '../../infrastructure/ai/types';
import { getFetchBridge } from '../settings/tabs/ai/types';
export interface ProviderModelCatalog {
models: ComposerPickerModel[];
fetched: boolean;
loading: boolean;
error?: string;
}
export function useProviderModelCatalog(
provider: ProviderConfig | undefined,
enabled: boolean,
): ProviderModelCatalog {
const cacheKey = provider && enabled ? providerModelCacheKey(provider) : '';
const providerRef = useRef(provider);
providerRef.current = provider;
const seed = useMemo(
() => {
if (!cacheKey) return { models: [], fetched: false };
const current = providerRef.current;
return current ? seedProviderModelCatalog(current) : { models: [], fetched: false };
},
[cacheKey],
);
const [catalog, setCatalog] = useState<Omit<ProviderModelCatalog, 'loading'>>(() => {
const current = providerRef.current;
const hit = current && enabled ? readCachedProviderModelCatalog(current) : null;
return hit ? { models: hit, fetched: true } : seed;
});
const [loading, setLoading] = useState(() => {
const current = providerRef.current;
return Boolean(enabled && current && !readCachedProviderModelCatalog(current));
});
useEffect(() => {
const current = providerRef.current;
if (!enabled || !current) {
setCatalog({ models: [], fetched: false });
setLoading(false);
return;
}
const hit = readCachedProviderModelCatalog(current);
if (hit) {
setCatalog((prev) => (
prev.fetched && prev.models === hit ? prev : { models: hit, fetched: true }
));
setLoading(false);
return;
}
let cancelled = false;
setCatalog(seedProviderModelCatalog(current));
setLoading(true);
void fetchProviderModelCatalog(current, getFetchBridge()).then((next) => {
if (cancelled) return;
setCatalog(next);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [cacheKey, enabled]);
return {
models: catalog.models.length > 0 ? catalog.models : seed.models,
fetched: catalog.fetched,
loading,
error: catalog.error,
};
}
@@ -0,0 +1,451 @@
import React, { useCallback, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { AppLockSystemUnlockStatus } from '../../../application/state/useAppLockState';
import {
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
type AppLockSettings,
type AppLockSettingsChangeError,
type AppLockTimeoutMinutes,
} from '../../../domain/appLock';
import { Button } from '../../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../ui/dialog';
import { Input } from '../../ui/input';
import { Label } from '../../ui/label';
import { Select, SettingCard, SettingHint, SettingRow, SectionHeader, Toggle } from '../settings-ui';
type AppLockDialogMode = 'setup' | 'change' | 'disable' | null;
interface AppLockSettingsSectionProps {
appLockSettings: AppLockSettings;
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
requestAppLockDisable: (
currentPassword: string,
) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
requestAppLockPasswordChange: (input: {
currentPassword?: string;
nextPassword: string;
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
setAppLockSystemUnlockEnabled?: (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => Promise<
AppLockSettings
| { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }
>;
}
export const AppLockSettingsSection: React.FC<AppLockSettingsSectionProps> = ({
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockDisable,
requestAppLockPasswordChange,
appLockSystemUnlockStatus,
setAppLockSystemUnlockEnabled,
}) => {
const { t } = useI18n();
const [dialogMode, setDialogMode] = useState<AppLockDialogMode>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [disablePassword, setDisablePassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isDisabling, setIsDisabling] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingSystemUnlock, setIsSavingSystemUnlock] = useState(false);
const hasPassword = Boolean(appLockSettings.passwordVerifier);
const isDialogBusy = isDisabling || isSavingPassword;
const showSystemUnlock = Boolean(
hasPassword
&& appLockSystemUnlockStatus?.supported
&& appLockSystemUnlockStatus.label
&& (appLockSystemUnlockStatus.available || appLockSettings.systemUnlockEnabled),
);
const timeoutOptions = APP_LOCK_TIMEOUT_OPTIONS_MINUTES.map((minutes) => ({
value: String(minutes),
label: t(`settings.appLock.timeout.${minutes}`),
}));
const mapChangeError = useCallback((
changeError: AppLockSettingsChangeError | 'locked' | 'unsupported' | 'unavailable' | 'failed',
): string => {
switch (changeError) {
case 'empty-current':
return t('settings.appLock.validation.currentRequired');
case 'empty-next':
return t('settings.appLock.validation.newRequired');
case 'incorrect':
return t('settings.appLock.validation.incorrect');
case 'locked':
return t('settings.appLock.systemUnlock.locked');
case 'unsupported':
case 'unavailable':
case 'failed':
return t('settings.appLock.systemUnlock.unavailable');
}
}, [t]);
const resetDialog = useCallback(() => {
setDialogMode(null);
setCurrentPassword('');
setDisablePassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
}, []);
const openDialog = useCallback((mode: Exclude<AppLockDialogMode, null>) => {
setError(null);
setDialogMode(mode);
}, []);
const handleTimeoutChange = useCallback((value: string) => {
const timeoutMinutes = Number(value) as AppLockTimeoutMinutes;
if (!APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(timeoutMinutes)) return;
setAppLockTimeoutMinutes(timeoutMinutes);
}, [setAppLockTimeoutMinutes]);
const handleDisable = useCallback(async () => {
setError(null);
setIsDisabling(true);
try {
const result = await requestAppLockDisable(disablePassword);
if ('ok' in result && result.ok === false) {
setError(mapChangeError(result.error));
return;
}
resetDialog();
} finally {
setIsDisabling(false);
}
}, [disablePassword, mapChangeError, requestAppLockDisable, resetDialog]);
const handleSavePassword = useCallback(async () => {
setError(null);
if (!newPassword.trim()) {
setError(t('settings.appLock.validation.newRequired'));
return;
}
if (!confirmPassword.trim()) {
setError(t('settings.appLock.validation.confirmRequired'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('settings.appLock.validation.mismatch'));
return;
}
setIsSavingPassword(true);
try {
const result = await requestAppLockPasswordChange({
currentPassword: hasPassword ? currentPassword : undefined,
nextPassword: newPassword,
});
if ('ok' in result && result.ok === false) {
setError(mapChangeError(result.error));
return;
}
resetDialog();
} finally {
setIsSavingPassword(false);
}
}, [
confirmPassword,
currentPassword,
hasPassword,
mapChangeError,
newPassword,
requestAppLockPasswordChange,
resetDialog,
t,
]);
const handleSystemUnlockChange = useCallback(async (enabled: boolean) => {
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
setError(null);
setIsSavingSystemUnlock(true);
try {
const result = await setAppLockSystemUnlockEnabled({
enabled,
autoPromptEnabled: enabled && appLockSettings.systemUnlockEnabled
? appLockSettings.systemUnlockAutoPromptEnabled
: false,
});
if ('ok' in result && result.ok === false) {
if (result.error === 'cancelled') return;
setError(mapChangeError(result.error));
}
} finally {
setIsSavingSystemUnlock(false);
}
}, [
appLockSettings.systemUnlockAutoPromptEnabled,
appLockSettings.systemUnlockEnabled,
appLockSystemUnlockStatus?.label,
mapChangeError,
setAppLockSystemUnlockEnabled,
]);
const handleAutoPromptChange = useCallback(async (autoPromptEnabled: boolean) => {
if (!setAppLockSystemUnlockEnabled || !appLockSystemUnlockStatus?.label) return;
if (!appLockSettings.systemUnlockEnabled) return;
setError(null);
setIsSavingSystemUnlock(true);
try {
const result = await setAppLockSystemUnlockEnabled({ enabled: true, autoPromptEnabled });
if ('ok' in result && result.ok === false) {
if (result.error === 'cancelled') return;
setError(mapChangeError(result.error));
}
} finally {
setIsSavingSystemUnlock(false);
}
}, [
appLockSettings.systemUnlockEnabled,
appLockSystemUnlockStatus?.label,
mapChangeError,
setAppLockSystemUnlockEnabled,
]);
const dialogTitle = dialogMode === 'disable'
? t('settings.appLock.disableTitle')
: dialogMode === 'change'
? t('settings.appLock.changePasswordTitle')
: t('settings.appLock.setupPasswordTitle');
const dialogDescription = dialogMode === 'disable'
? t('settings.appLock.disableDescription')
: dialogMode === 'change'
? t('settings.appLock.changePasswordDescription')
: t('settings.appLock.setupPasswordDescription');
return (
<>
<SectionHeader title={t('settings.appLock.title')} anchorId="system-app-lock" />
<SettingCard divided>
{!hasPassword ? (
<SettingRow
label={t('settings.appLock.setupTitle')}
description={t('settings.appLock.setupDescription')}
>
<Button type="button" size="sm" onClick={() => openDialog('setup')}>
{t('settings.appLock.savePassword')}
</Button>
</SettingRow>
) : (
<>
<SettingRow
label={t('settings.appLock.manageTitle')}
description={appLockSettings.enabled
? t('settings.appLock.enabledStatus')
: t('settings.appLock.disabledStatus')}
>
<span className={appLockSettings.enabled
? 'text-xs font-medium text-emerald-600 dark:text-emerald-400'
: 'text-xs font-medium text-muted-foreground'}
>
{t(appLockSettings.enabled ? 'common.enabled' : 'common.disabled')}
</span>
</SettingRow>
<SettingRow
label={t('settings.appLock.timeout')}
description={t('settings.appLock.timeoutDesc')}
>
<Select
value={String(appLockSettings.timeoutMinutes)}
options={timeoutOptions}
onChange={handleTimeoutChange}
className="w-36"
/>
</SettingRow>
{showSystemUnlock && appLockSystemUnlockStatus?.label && (
<>
<SettingRow
label={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
description={appLockSystemUnlockStatus.available
? t('settings.appLock.systemUnlock.desc').replace('{label}', appLockSystemUnlockStatus.label)
: t('settings.appLock.systemUnlock.unavailableDesc').replace('{label}', appLockSystemUnlockStatus.label)}
>
<Toggle
checked={appLockSettings.systemUnlockEnabled}
disabled={
isSavingSystemUnlock
|| (!appLockSystemUnlockStatus.available && !appLockSettings.systemUnlockEnabled)
}
ariaLabel={t('settings.appLock.systemUnlock.label').replace('{label}', appLockSystemUnlockStatus.label)}
onChange={(enabled) => void handleSystemUnlockChange(enabled)}
/>
</SettingRow>
<SettingRow
label={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
description={t('settings.appLock.systemUnlock.autoPrompt.desc').replace('{label}', appLockSystemUnlockStatus.label)}
>
<Toggle
checked={appLockSettings.systemUnlockAutoPromptEnabled}
disabled={
isSavingSystemUnlock
|| !appLockSettings.systemUnlockEnabled
|| !appLockSystemUnlockStatus.available
}
ariaLabel={t('settings.appLock.systemUnlock.autoPrompt.label').replace('{label}', appLockSystemUnlockStatus.label)}
onChange={(enabled) => void handleAutoPromptChange(enabled)}
/>
</SettingRow>
</>
)}
<SettingRow
label={t('settings.appLock.changePasswordTitle')}
description={t('settings.appLock.changePasswordDescription')}
>
<Button type="button" size="sm" variant="outline" onClick={() => openDialog('change')}>
{t('settings.appLock.replacePassword')}
</Button>
</SettingRow>
{appLockSettings.enabled && (
<SettingRow
label={t('settings.appLock.disableTitle')}
description={t('settings.appLock.disableDescription')}
>
<Button type="button" size="sm" variant="destructive" onClick={() => openDialog('disable')}>
{t('settings.appLock.disable')}
</Button>
</SettingRow>
)}
</>
)}
</SettingCard>
<SettingHint>{t('settings.appLock.localOnlyHint')}</SettingHint>
{error && dialogMode === null && (
<p className="mt-2 text-xs text-destructive">{error}</p>
)}
<Dialog
open={dialogMode !== null}
onOpenChange={(open) => {
if (!open && !isDialogBusy) resetDialog();
}}
>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (dialogMode === 'disable') {
void handleDisable();
} else {
void handleSavePassword();
}
}}
>
<div className="space-y-3">
{dialogMode === 'disable' && (
<div className="space-y-2">
<Label htmlFor="app-lock-disable-password">{t('settings.appLock.currentPassword')}</Label>
<Input
id="app-lock-disable-password"
type="password"
value={disablePassword}
autoComplete="current-password"
placeholder={t('settings.appLock.currentPasswordForDisablePlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setDisablePassword(event.target.value);
setError(null);
}}
/>
</div>
)}
{dialogMode === 'change' && (
<div className="space-y-2">
<Label htmlFor="app-lock-current-password">{t('settings.appLock.currentPassword')}</Label>
<Input
id="app-lock-current-password"
type="password"
value={currentPassword}
autoComplete="current-password"
placeholder={t('settings.appLock.currentPasswordForChangePlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setCurrentPassword(event.target.value);
setError(null);
}}
/>
</div>
)}
{dialogMode !== 'disable' && (
<>
<div className="space-y-2">
<Label htmlFor="app-lock-new-password">{t('settings.appLock.newPassword')}</Label>
<Input
id="app-lock-new-password"
type="password"
value={newPassword}
autoComplete="new-password"
placeholder={t('settings.appLock.newPasswordPlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setNewPassword(event.target.value);
setError(null);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="app-lock-confirm-password">{t('settings.appLock.confirmPassword')}</Label>
<Input
id="app-lock-confirm-password"
type="password"
value={confirmPassword}
autoComplete="new-password"
placeholder={t('settings.appLock.confirmPasswordPlaceholder')}
disabled={isDialogBusy}
onChange={(event) => {
setConfirmPassword(event.target.value);
setError(null);
}}
/>
</div>
</>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={isDialogBusy} onClick={resetDialog}>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant={dialogMode === 'disable' ? 'destructive' : 'default'}
disabled={isDialogBusy}
>
{dialogMode === 'disable'
? (isDisabling ? t('settings.appLock.disabling') : t('settings.appLock.disable'))
: (isSavingPassword ? t('settings.appLock.savingPassword') : t('settings.appLock.savePassword'))}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
};
@@ -6,6 +6,7 @@ import { useI18n } from "../../../application/i18n/I18nProvider";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { SectionHeader, Select, SettingsAnchor, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
export default function SettingsShortcutsTab(props: {
hotkeyScheme: HotkeyScheme;
@@ -65,6 +66,8 @@ export default function SettingsShortcutsTab(props: {
const specialSuffix = getSpecialSuffix(recordingBindingId);
const handleKeyDown = (e: KeyboardEvent) => {
// Skip while app lock overlay is up so password keys never bind (Codex P2).
if (isAppLockOverlayActive()) return;
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const readAppLockSectionSource = () => (
readFileSync(new URL("./AppLockSettingsSection.tsx", import.meta.url), "utf8")
);
test("disabling app lock does not trigger a second renderer-side unlock request", () => {
const source = readAppLockSectionSource();
assert.doesNotMatch(source, /unlockApp\?\.\(/);
assert.doesNotMatch(source, /unlockApp\?:/);
});
test("app lock setup starts with password setup instead of a misleading enable toggle", () => {
const source = readAppLockSectionSource();
assert.match(source, /!hasPassword \? \(/);
assert.match(source, /settings\.appLock\.setupTitle/);
assert.match(source, /settings\.appLock\.setupDescription/);
assert.match(source, /settings\.appLock\.manageTitle/);
assert.doesNotMatch(
source,
/hasAppLockPassword \? t\("settings\.appLock\.enableDesc"\) : t\("settings\.appLock\.enableAfterPassword"\)/,
);
});
test("app lock section exposes settings-search anchors", () => {
const source = readAppLockSectionSource();
assert.match(source, /anchorId="system-app-lock"/);
});
test("app lock disable handler uses its modal current password field", () => {
const source = readAppLockSectionSource();
const handlerStart = source.indexOf("const handleDisable = useCallback");
const handlerEnd = source.indexOf("const handleSavePassword", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
assert.match(handlerSource, /requestAppLockDisable\(disablePassword\)/);
assert.match(handlerSource, /disablePassword,/);
assert.doesNotMatch(source, /handleAppLockEnabledChange/);
});
test("app lock page uses settings rows and moves password forms into a dialog", () => {
const source = readAppLockSectionSource();
const dialogStart = source.indexOf('<Dialog');
const firstPasswordInput = source.indexOf('<Input');
assert.match(source, /<SettingCard divided>/);
assert.match(source, /<SettingRow/);
assert.match(source, /<DialogContent/);
assert.ok(firstPasswordInput > dialogStart, "password inputs should only render inside the dialog");
assert.doesNotMatch(source.slice(0, dialogStart), /<Input/);
assert.doesNotMatch(source, /settings\.appLock\.enableDesc/);
assert.match(source, /settings\.appLock\.disableTitle/);
assert.match(source, /settings\.appLock\.disableDescription/);
assert.match(source, /settings\.appLock\.disable/);
assert.match(source, /settings\.appLock\.changePasswordTitle/);
assert.match(source, /settings\.appLock\.currentPasswordForDisablePlaceholder/);
assert.match(source, /settings\.appLock\.currentPasswordForChangePlaceholder/);
});
test("app lock system unlock setting uses bridge-provided platform label", () => {
const source = readAppLockSectionSource();
assert.match(source, /showSystemUnlock/);
assert.match(source, /appLockSystemUnlockStatus\.label/);
assert.match(source, /settings\.appLock\.systemUnlock\.label/);
assert.doesNotMatch(source, /navigator\.platform/);
});
test("app lock system unlock enablement does not require current password in settings", () => {
const source = readAppLockSectionSource();
const handlerStart = source.indexOf("const handleSystemUnlockChange = useCallback");
const handlerEnd = source.indexOf("const handleAutoPromptChange", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
assert.match(handlerSource, /setAppLockSystemUnlockEnabled\(\{/);
assert.doesNotMatch(handlerSource, /appLockSystemUnlockPassword/);
assert.doesNotMatch(handlerSource, /currentPassword:/);
const systemUnlockStart = source.indexOf("{showSystemUnlock");
const systemUnlockEnd = source.indexOf("settings.appLock.changePasswordTitle", systemUnlockStart);
const systemUnlockSection = source.slice(systemUnlockStart, systemUnlockEnd);
assert.doesNotMatch(systemUnlockSection, /settings\.appLock\.currentPassword/);
});
test("app lock system unlock setting hides when unavailable unless already enabled", () => {
const source = readAppLockSectionSource();
assert.match(source, /appLockSystemUnlockStatus\.available \|\| appLockSettings\.systemUnlockEnabled/);
// Already-enabled toggle stays clickable when unavailable so the user can disable.
assert.match(
source,
/disabled=\{\s*isSavingSystemUnlock\s*\|\|\s*\(!appLockSystemUnlockStatus\.available && !appLockSettings\.systemUnlockEnabled\)\s*\}/,
);
});
test("app lock system unlock exposes auto prompt as a child option", () => {
const source = readAppLockSectionSource();
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.label/);
assert.match(source, /settings\.appLock\.systemUnlock\.autoPrompt\.desc/);
assert.match(source, /appLockSettings\.systemUnlockAutoPromptEnabled/);
assert.match(source, /appLockSettings\.systemUnlockEnabled/);
});
test("app lock system unlock handles native verification failures", () => {
const source = readAppLockSectionSource();
assert.match(source, /'cancelled' \| 'failed'/);
assert.match(source, /result\.error === 'cancelled'/);
assert.match(source, /case 'failed':\s*return t\('settings\.appLock\.systemUnlock\.unavailable'\)/);
});
test("app lock disable explains that turning it off removes the saved password", () => {
const englishLocale = readFileSync(new URL("../../../application/i18n/locales/en/core.ts", import.meta.url), "utf8");
assert.match(englishLocale, /saved password will be removed/);
assert.match(englishLocale, /requires creating a new one/);
});
+33 -1
View File
@@ -4,6 +4,8 @@
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
@@ -13,6 +15,8 @@ import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui";
import { cn } from "../../../lib/utils";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
import { AppLockSettingsSection } from './AppLockSettingsSection';
interface CrashLogFile {
fileName: string;
@@ -77,6 +81,19 @@ function formatLastChecked(
}
interface SettingsSystemTabProps {
appLockSettings: AppLockSettings;
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
requestAppLockDisable: (currentPassword: string) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
requestAppLockPasswordChange: (input: {
currentPassword?: string;
nextPassword: string;
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
setAppLockSystemUnlockEnabled?: (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => Promise<AppLockSettings | { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }>;
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
sessionLogsDir: string;
@@ -120,6 +137,12 @@ interface SettingsSystemTabProps {
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockDisable,
requestAppLockPasswordChange,
appLockSystemUnlockStatus,
setAppLockSystemUnlockEnabled,
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
@@ -403,6 +426,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
if (!isRecordingHotkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
e.preventDefault();
e.stopPropagation();
@@ -441,7 +465,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
];
return (
<SettingsTabContent value="system">
<SectionHeader title={t('settings.update.title')} anchorId="system-update" />
@@ -643,6 +666,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.system.networkProxy.hint")}
</SettingHint>
<AppLockSettingsSection
appLockSettings={appLockSettings}
setAppLockTimeoutMinutes={setAppLockTimeoutMinutes}
requestAppLockDisable={requestAppLockDisable}
requestAppLockPasswordChange={requestAppLockPasswordChange}
appLockSystemUnlockStatus={appLockSystemUnlockStatus}
setAppLockSystemUnlockEnabled={setAppLockSystemUnlockEnabled}
/>
<SectionHeader title={t("settings.system.credentials.title")} />
<SettingsAnchor anchorId="system-credentials">
<SettingCard className="space-y-3 py-4">
@@ -31,3 +31,10 @@ test("terminal behavior settings expose disabled auto-close", () => {
/settings\.terminal\.behavior\.autoCloseOnExit[\s\S]*?role="switch" aria-checked="false"/,
);
});
test("terminal behavior settings expose OSC desktop notification mode", () => {
const markup = renderSettings(true);
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications/);
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications\.always/);
});
@@ -1,6 +1,6 @@
import React from "react";
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
@@ -204,6 +204,23 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
/>
</SettingRow>
<SettingRow
anchorId="terminal-osc-notifications"
label={t("settings.terminal.behavior.oscNotifications")}
description={t("settings.terminal.behavior.oscNotifications.desc")}
>
<Select
value={terminalSettings.oscNotifications ?? "always"}
options={[
{ value: "always", label: t("settings.terminal.behavior.oscNotifications.always") },
{ value: "unfocused", label: t("settings.terminal.behavior.oscNotifications.unfocused") },
{ value: "off", label: t("settings.terminal.behavior.oscNotifications.off") },
]}
onChange={(v) => updateTerminalSetting("oscNotifications", v as OscNotificationMode)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="terminal-osc52-clipboard"
label={t("settings.terminal.behavior.osc52Clipboard")}
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import {
probeProviderConnection,
@@ -53,6 +54,10 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
}
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
export const ProviderConfigForm: React.FC<{
provider: ProviderConfig;
@@ -72,6 +77,7 @@ export const ProviderConfigForm: React.FC<{
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
style: provider.style ?? "",
openaiApi: resolveOpenAIApi(provider),
iconId: provider.iconId ?? "",
iconDataUrl: provider.iconDataUrl ?? "",
});
@@ -91,6 +97,9 @@ export const ProviderConfigForm: React.FC<{
const preset = PROVIDER_PRESETS[provider.providerId];
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
const resolvedBaseURL = provider.providerId === "ollama"
? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "")
: (form.baseURL || preset?.defaultBaseURL || "");
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
providerId: provider.providerId,
baseURL: form.baseURL || preset?.defaultBaseURL || "",
@@ -220,7 +229,7 @@ export const ProviderConfigForm: React.FC<{
}, []);
const handleTestConnection = useCallback(async () => {
const baseURL = form.baseURL || preset?.defaultBaseURL || "";
const baseURL = resolvedBaseURL;
const inputCheck = validateProviderProbeInputs({
baseURL,
apiKey: form.apiKey,
@@ -301,7 +310,7 @@ export const ProviderConfigForm: React.FC<{
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, provider.providerId, resolvedStyle, t]);
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
@@ -329,13 +338,16 @@ export const ProviderConfigForm: React.FC<{
const updates: Partial<ProviderConfig> = {
name: trimmedName || defaultName,
baseURL: form.baseURL || undefined,
baseURL: provider.providerId === "ollama"
? resolvedBaseURL
: (form.baseURL || undefined),
defaultModel: form.defaultModel || undefined,
contextWindow: manualContextWindow,
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
style: form.style || undefined,
openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined,
iconId: form.iconId || undefined,
iconDataUrl: form.iconDataUrl || undefined,
};
@@ -348,7 +360,7 @@ export const ProviderConfigForm: React.FC<{
}
onSave(updates);
}, [form, onSave, provider.providerId, t]);
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
@@ -475,6 +487,34 @@ export const ProviderConfigForm: React.FC<{
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
</div>
{resolvedStyle === "openai" && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.openaiApi')}</label>
<div className="flex items-center gap-1.5">
{OPENAI_API_OPTIONS.map((format) => {
const isSelected = form.openaiApi === format;
return (
<button
key={format}
type="button"
onClick={() => setForm((prev) => ({ ...prev, openaiApi: format }))}
className={cn(
"h-7 px-2.5 rounded-md text-xs border transition-colors",
isSelected
? "border-primary/70 bg-primary/15 text-foreground"
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
aria-pressed={isSelected}
>
{t(`ai.providers.openaiApi.${format}`)}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.openaiApi.help')}</p>
</div>
)}
{/* API Key */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
@@ -512,6 +552,9 @@ export const ProviderConfigForm: React.FC<{
{resolvedStyle === "anthropic" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
) : null}
{provider.providerId === "ollama" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.ollamaHelp')}</p>
) : null}
</div>
{/* Default Model */}
@@ -526,7 +569,7 @@ export const ProviderConfigForm: React.FC<{
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
}));
}}
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
baseURL={resolvedBaseURL}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
@@ -662,20 +705,26 @@ export const ProviderConfigForm: React.FC<{
{/* Actions */}
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Button variant="default" size="sm" onClick={() => void handleSave()}>
<Check size={14} className="mr-1.5" />
<Button
variant="default"
size="sm"
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
onClick={() => void handleSave()}
>
<Check size={14} className="size-3.5 shrink-0" />
{t('common.save')}
</Button>
<Button
variant="outline"
size="sm"
className={PROVIDER_ACTION_CLASS}
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
>
<RefreshCw size={14} className={cn("mr-1.5", isTesting && "animate-spin")} />
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
<Button variant="ghost" size="sm" className={PROVIDER_ACTION_CLASS} onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
+2
View File
@@ -5,6 +5,7 @@ import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
OpenAIApiFormat,
ProviderStyle,
} from "../../../../infrastructure/ai/types";
@@ -124,6 +125,7 @@ export interface ProviderFormState {
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
style: ProviderStyle | ""; // "" means inherit-from-providerId
openaiApi: OpenAIApiFormat;
iconId: string; // "" means no built-in pick (fall back to providerId)
iconDataUrl: string; // "" means no upload override
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const SftpColumnMenuItems: React.FC<SftpColumnMenuItemsProps> = ({
<ContextMenuCheckboxItem checked disabled>
{t('sftp.columns.name')}
</ContextMenuCheckboxItem>
{(['modified', 'size', 'type'] as const).map((field) => (
{(['modified', 'size', 'type', 'owner'] as const).map((field) => (
<ContextMenuCheckboxItem
key={field}
checked={visibleColumns[field]}
+1
View File
@@ -59,6 +59,7 @@ export interface SftpPaneCallbacks {
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
// External file upload from <input type="file" multiple> picker (FileList).
+9 -1
View File
@@ -141,6 +141,11 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
</span>
)}
{visibleColumns.owner && (
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isParentDir ? '' : (entry.owner || '--')}
</span>
)}
</div>
);
};
@@ -155,9 +160,11 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
if (prev.columnWidths.size !== next.columnWidths.size) return false;
if (prev.columnWidths.type !== next.columnWidths.type) return false;
if (prev.columnWidths.owner !== next.columnWidths.owner) return false;
if (prev.visibleColumns.modified !== next.visibleColumns.modified) return false;
if (prev.visibleColumns.size !== next.visibleColumns.size) return false;
if (prev.visibleColumns.type !== next.visibleColumns.type) return false;
if (prev.visibleColumns.owner !== next.visibleColumns.owner) return false;
// Compare callbacks - important for ".." entry which has static properties
if (prev.onOpen !== next.onOpen) return false;
if (prev.onSelect !== next.onSelect) return false;
@@ -170,7 +177,8 @@ const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
prevEntry.lastModified === nextEntry.lastModified &&
prevEntry.linkTarget === nextEntry.linkTarget &&
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted &&
prevEntry.owner === nextEntry.owner
);
};
+29 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppWindow, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -17,6 +17,7 @@ import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { isExtractableArchive } from "../../domain/sftpArchive";
import { SftpFileRow } from "./SftpFileRow";
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
@@ -65,6 +66,7 @@ interface SftpPaneFileListProps {
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onExtractArchive?: (entry: SftpFileEntry) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
@@ -153,6 +155,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onEditFile,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
@@ -347,6 +350,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
{t("sftp.context.download")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
<Archive size={14} className="mr-2" />{" "}
{t("sftp.context.extract")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@@ -472,6 +481,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onDragEnd,
onEditFile,
onEditPermissions,
@@ -597,7 +607,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
)}
{visibleColumns.type && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
@@ -606,6 +616,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
</span>
)}
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("type", e)}
/>
</div>
)}
{visibleColumns.owner && (
<div
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
onClick={() => handleSort("owner")}
>
{sortField === "owner" && (
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span className="truncate whitespace-nowrap">{t("sftp.columns.owner")}</span>
</div>
)}
</div>
@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
test("selecting an inline SFTP bookmark closes its popover", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("HTMLInputElement", window.HTMLInputElement);
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("StorageEvent", window.StorageEvent);
installGlobal("localStorage", window.localStorage);
installGlobal("sessionStorage", window.sessionStorage);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];
try {
await act(async () => {
root.render(
React.createElement(SftpPaneToolbar, {
t: (key) => ({
"sftp.bookmark.list": "Bookmarked paths",
"sftp.bookmark.remove": "Remove bookmark",
"sftp.viewMode.switchToTree": "Switch to tree view",
}[key] ?? key),
pane: {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/home/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
},
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: (path) => navigatedPaths.push(path),
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
});
const trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
await act(async () => trigger.click());
const bookmark = Array.from(window.document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Web root"),
);
assert.ok(bookmark, "bookmark path should be visible after opening the popover");
await act(async () => bookmark.click());
assert.deepEqual(navigatedPaths, ["/srv/www"]);
assert.equal(
Array.from(window.document.querySelectorAll("button")).some((button) =>
button.textContent?.includes("Web root"),
),
false,
"bookmark popover should close after selecting a path",
);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+13 -8
View File
@@ -19,6 +19,7 @@ import {
SftpPaneToolbar,
} from "./SftpPaneToolbar.tsx";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import { Popover } from "../ui/popover.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const toolbarSource = fs.readFileSync(
@@ -495,14 +496,18 @@ test("bookmark list renders saved paths as selectable rows", () => {
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
React.createElement(
Popover,
null,
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
),
),
);
+10 -8
View File
@@ -251,14 +251,16 @@ export const SftpBookmarkList: React.FC<SftpBookmarkListProps> = ({
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<PopoverClose asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</PopoverClose>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
+5
View File
@@ -121,6 +121,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
{isParentEntry ? '' : (isDir ? t('sftp.kind.folder') : (entry.name.split('.').pop()?.toUpperCase() ?? '--'))}
</span>
)}
{visibleColumns.owner && (
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
{isParentEntry ? '' : (entry.owner || '--')}
</span>
)}
</div>
);
});
+22 -1
View File
@@ -44,6 +44,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onOpenFileWith,
onEditFile,
onDownloadFile,
onExtractArchive,
onEditPermissions,
draggedFiles,
openNewFolderDialog,
@@ -197,6 +198,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onEditFileRef.current = onEditFile;
const onDownloadFileRef = useRef(onDownloadFile);
onDownloadFileRef.current = onDownloadFile;
const onExtractArchiveRef = useRef(onExtractArchive);
onExtractArchiveRef.current = onExtractArchive;
const onEditPermissionsRef = useRef(onEditPermissions);
onEditPermissionsRef.current = onEditPermissions;
const openRenameDialogRef = useRef(openRenameDialog);
@@ -917,6 +920,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
onOpenFileWithRef,
onEditFileRef,
onDownloadFileRef,
onExtractArchiveRef,
onEditPermissionsRef,
openDeleteConfirmRef,
openRenameDialogRef,
@@ -996,7 +1000,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
)}
{visibleColumns.type && (
<div
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground min-w-0 overflow-hidden"
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
onClick={() => handleSort('type')}
>
{sortField === 'type' && (
@@ -1005,6 +1009,23 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
</span>
)}
<span className="truncate whitespace-nowrap">{t('sftp.columns.kind')}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart('type', e)}
/>
</div>
)}
{visibleColumns.owner && (
<div
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground min-w-0 overflow-hidden"
onClick={() => handleSort('owner')}
>
{sortField === 'owner' && (
<span className="shrink-0 text-primary">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
<span className="truncate whitespace-nowrap">{t('sftp.columns.owner')}</span>
</div>
)}
</div>
@@ -23,6 +23,7 @@ export interface SftpPaneTreeViewProps {
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
openNewFolderDialog: (targetPath: string) => void;
+11 -1
View File
@@ -19,7 +19,7 @@ import {
useSftpWritableHosts,
} from "./SftpContext";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
@@ -328,6 +328,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
const parentPath = getParentPath(archivePath);
if (parentPath) requestNestedTreeReload([parentPath]);
});
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
@@ -602,6 +610,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
@@ -653,6 +662,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
@@ -23,6 +23,7 @@ import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { toast } from "../../ui/toast";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
import {
createDropEntriesFromClipboardFiles,
getSftpClipboardSystemTextPaths,
@@ -992,8 +993,12 @@ export const useSftpKeyboardShortcuts = ({
useEffect(() => {
if (!isActive) return;
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
const onKeyDown = (event: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
handleKeyDown(event);
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [handleKeyDown, isActive]);
useEffect(() => {
@@ -8,6 +8,7 @@ import {
} from "../../../application/state/sftp/transferConcurrency";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../utils";
import { reportSftpUploadResults } from "../reportSftpUploadResults";
@@ -535,6 +536,52 @@ export const useSftpViewFileOps = ({
[handleDownloadFileForSide],
);
const handleExtractArchiveForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
try {
const bridge = netcattyBridge.get();
if (pane.connection.isLocal) {
if (!bridge?.extractLocalArchive) {
throw new Error("Local extract unavailable");
}
await bridge.extractLocalArchive(resolvedPath);
} else {
const sftpId = getSftpIdForConnection?.(pane.connection.id);
if (!sftpId || !bridge?.extractSftpArchive) {
throw new Error("SFTP session not found");
}
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
}
await sftpRef.current.refresh(side);
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to extract archive:", e);
toast.error(
t("sftp.extract.error", {
fileName: file.name,
error: e instanceof Error ? e.message : String(e),
}),
"SFTP",
);
}
},
[getSftpIdForConnection, sftpRef, t],
);
const onExtractArchiveLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
[handleExtractArchiveForSide],
);
const onExtractArchiveRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
[handleExtractArchiveForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
@@ -738,6 +785,8 @@ export const useSftpViewFileOps = ({
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
@@ -81,6 +81,8 @@ export interface UseSftpViewFileOpsResult {
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
@@ -90,6 +90,7 @@ export const useSftpViewPaneCallbacks = ({
lastModified: ms,
lastModifiedFormatted: formatDate(ms),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
hidden: f.hidden,
};
@@ -154,6 +155,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
@@ -196,6 +198,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
+24 -2
View File
@@ -14,6 +14,7 @@ const widths: ColumnWidths = {
modified: 28,
size: 7,
type: 9,
owner: 10,
};
test('normalizes missing and invalid SFTP column preferences to all columns', () => {
@@ -23,8 +24,15 @@ test('normalizes missing and invalid SFTP column preferences to all columns', ()
test('keeps the name column visible while restoring optional column preferences', () => {
assert.deepEqual(
normalizeSftpColumnVisibility({ name: false, modified: false, size: true, type: false }),
{ name: true, modified: false, size: true, type: false },
normalizeSftpColumnVisibility({ name: false, modified: false, size: true, type: false, owner: false }),
{ name: true, modified: false, size: true, type: false, owner: false },
);
});
test('treats a missing owner preference as visible', () => {
assert.equal(
normalizeSftpColumnVisibility({ name: true, modified: true, size: true, type: true }).owner,
true,
);
});
@@ -34,11 +42,24 @@ test('builds a grid containing only visible SFTP columns', () => {
modified: false,
size: true,
type: false,
owner: false,
});
assert.equal(template, 'minmax(140px, 56fr) minmax(52px, 7fr)');
});
test('includes the owner column when it is visible', () => {
const template = buildSftpColumnTemplate(widths, {
name: true,
modified: false,
size: false,
type: false,
owner: true,
});
assert.equal(template, 'minmax(140px, 56fr) minmax(56px, 10fr)');
});
test('can reduce the SFTP file list to only the name column', () => {
assert.equal(
buildSftpColumnTemplate(widths, {
@@ -46,6 +67,7 @@ test('can reduce the SFTP file list to only the name column', () => {
modified: false,
size: false,
type: false,
owner: false,
}),
'minmax(140px, 56fr)',
);
@@ -27,4 +27,9 @@ test('list and tree SFTP views share column visibility and keyboard-accessible m
assert.match(treeNodeSource, /visibleColumns\.modified/);
assert.match(treeNodeSource, /visibleColumns\.size/);
assert.match(treeNodeSource, /visibleColumns\.type/);
assert.match(treeNodeSource, /visibleColumns\.owner/);
assert.match(columnMenuSource, /'modified', 'size', 'type', 'owner'/);
const paneCallbacksSource = readFileSync(new URL('./hooks/useSftpViewPaneCallbacks.ts', import.meta.url), 'utf8');
assert.match(paneCallbacksSource, /owner: f\.owner/);
});
+12
View File
@@ -59,6 +59,18 @@ test('SFTP descending kind sorting keeps directories first when enabled', () =>
assert.deepEqual(sorted.map(({ name }) => name), ['src', 'archive.zip', 'BUILD']);
});
test('SFTP owner sorting compares usernames and keeps directories first', () => {
const ownerEntries = [
{ ...entry('www.log', 'file', 100), owner: 'www-data' },
{ ...entry('root.txt', 'file', 100), owner: 'root' },
{ ...entry('home', 'directory', 100), owner: 'alice' },
];
const sorted = sortSftpEntries(ownerEntries, 'owner', 'asc', true);
assert.deepEqual(sorted.map(({ name }) => name), ['home', 'root.txt', 'www.log']);
});
test('SFTP kind sorting can mix files and directories when folders first is disabled', () => {
const kindEntries = [
entry('BUILD', 'file', 100),
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { AppWindow, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
import { getParentPath } from '../../application/state/sftp/utils';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
import { isExtractableArchive } from '../../domain/sftpArchive';
import { isNavigableDirectory } from './utils';
import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey } from './sftpUploadMenu';
@@ -15,7 +16,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
openNewFolderDialogRef, openNewFileDialogRef,
} = props;
@@ -82,6 +83,11 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
<Download size={14} className="mr-2" />{tRef.current('sftp.context.download')}
</ContextMenuItem>
)}
{!isDir && onExtractArchiveRef.current && isExtractableArchive(entry.name) && (
<ContextMenuItem onClick={() => onExtractArchiveRef.current?.(entry, entryPath)}>
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyToOtherPane}>
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
@@ -175,6 +181,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
onUploadExternalFolder,
onCopyToOtherPaneRef,
onDownloadFileRef,
onExtractArchiveRef,
onEditFileRef,
onEditPermissionsRef,
onNavigateToRef,
+4
View File
@@ -265,6 +265,7 @@ export const buildSftpColumnTemplate = (
if (visibleColumns.modified) columns.push(`minmax(0, ${columnWidths.modified}fr)`);
if (visibleColumns.size) columns.push(`minmax(52px, ${columnWidths.size}fr)`);
if (visibleColumns.type) columns.push(`minmax(64px, ${columnWidths.type}fr)`);
if (visibleColumns.owner) columns.push(`minmax(56px, ${columnWidths.owner}fr)`);
return columns.join(' ');
};
@@ -306,6 +307,9 @@ export const sortSftpEntries = (
cmp = extA.localeCompare(extB);
break;
}
case 'owner':
cmp = (a.owner || '').localeCompare(b.owner || '');
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
+2
View File
@@ -11,6 +11,7 @@ import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
import { CustomThemeEditor } from './CustomThemeEditor';
import { Button } from '../ui/button';
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
interface CustomThemeModalProps {
@@ -132,6 +133,7 @@ export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more