Compare commits

...

159 Commits

Author SHA1 Message Date
bincxz
be7aa4ae52 fix: resolve eslint warnings in App.tsx and VaultView.tsx
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:57:19 +08:00
陈大猫
f4872099bd fix: real-time session logging via main process streams (#403)
* fix: implement real-time session logging via main process streams

Fixes #394. Session logs previously only captured ~55 lines (the
xterm serialize buffer) and were written only on session close. This
change intercepts terminal data in the main process and writes it to
a file stream in real-time, capturing the complete session output.

- Add sessionLogStreamManager.cjs: manages per-session write streams
  with 500ms/64KB flush, supports txt/raw/html formats
- sshBridge: start stream on shell open, append on data, stop on close
- terminalBridge: same for local, telnet, mosh, serial sessions
- Thread sessionLog config from renderer settings through IPC options
- Skip old renderer-side auto-save when streaming is active
- Cleanup all streams on app quit

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

* fix: remove stale renderer-side auto-save and async HTML finalization

- Remove dead renderer-side auto-save code (main process handles it)
- Make stopStream async, await writeStream finish before HTML conversion
- Use fs.promises for HTML read/write/unlink

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:44:54 +08:00
陈大猫
4e2089d7e2 feat: add option to auto-open sidebar on host connect (#401)
* feat: add option to auto-open sidebar on host connect

Closes #396

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

* fix: only auto-open SFTP sidebar for SSH/Mosh connections

Use allowlist (ssh, mosh) instead of blocklist so telnet and other
non-SSH protocols don't trigger SFTP sidebar which would fail.

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

* fix: support auto-open SFTP for Quick Connect / temporary sessions

Build a minimal Host from session data when hostId is not in the vault,
so Quick Connect sessions also trigger auto-open SFTP sidebar.

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

* fix: sync SFTP auto-open sidebar setting across windows via IPC

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

* fix: skip local terminals and preserve username for temp sessions

- Don't fallback protocol to 'ssh' so local terminals are excluded
- Include session.username in synthesized Host for Quick Connect

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:12:53 +08:00
陈大猫
5f28320c57 fix: suppress known_hosts toast on auto-scan at startup (#402)
* fix: suppress known_hosts toast on auto-scan at startup

The auto-scan on first mount now runs silently — no toasts for missing
known_hosts file, no entries, or no new hosts. Users still see toasts
when manually clicking "Scan System".

Closes #398

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

* fix: wrap onClick handlers to avoid passing event as silent flag

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:44:08 +08:00
陈大猫
4e26852482 feat: support multimodal attachments in AI chat (#397)
* feat: support multimodal attachments (images, PDFs, files) in AI chat

Previously uploaded images were displayed in the UI but never sent to
the AI model, and non-image files (PDF, text) were silently rejected.

- Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/*
- Rename ChatMessageImage → ChatMessageAttachment with filePath support
- Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent
- Fix ACP agent path: images inline, non-image files via local path hint
  so ACP agents (Claude Code, etc.) read them with native file access
- Use Electron webUtils.getPathForFile() for reliable file path capture
- Compact user message bubble padding

Closes #294 (AI file upload issues)

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

* fix: show real tool names in AI chat instead of ACP wrapper names

- Unwrap ACP dynamic tool calls in serializeStreamChunk to extract
  real tool name, args, and toolCallId from chunk.input
- Simplify MCP tool name prefixes (mcp__server__tool → tool)
- Pass toolCallId from ACP tool-call events to match tool results
- Prevent onToolResult from overwriting correct names with wrapper name
- Build toolCallNames map in ChatMessageList for tool result display

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

* fix: backward-compatible fallback for legacy `images` field in chat messages

Persisted sessions may still have `images` instead of `attachments`.
Add `?? m.images` fallback in SDK message builder and renderer so
historical image attachments are not silently dropped after upgrade.

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

* fix: broaden file type support and handle pasted files without path

- Accept all file types except video/audio (instead of allowlist)
  so .json, .yaml, .sh, etc. are not silently rejected
- For ACP agents, save pasted/virtual files (no filePath) to temp
  directory so the agent can still read them

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

* fix: use managed temp dir for pasted ACP attachments

Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path
so pasted file attachments are tracked by the app's cleanup system.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:45:50 +08:00
yuzifu
c4fb19cafb update supported distros (#395) 2026-03-19 09:31:22 +08:00
bincxz
09e6526142 Remove GIFs, align zh-CN and ja-JP READMEs with main
- Delete all GIF files (replaced by mp4/user-attachments)
- Update demo sections to use GitHub video attachments
- Add contributor avatars via contrib.rocks
- Add Star History chart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:59:42 +08:00
陈大猫
7ce110c3fb Update asset links in README.md
Updated asset links for various features in the README.
2026-03-19 01:52:27 +08:00
bincxz
667ee18ed3 Compress demo mp4 files (~52MB → ~2.5MB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:50:23 +08:00
陈大猫
f969b1b73d Add links for SFTP and drag file upload sections
Updated README to include links for SFTP and drag file upload.
2026-03-19 01:43:47 +08:00
陈大猫
58a4bf892a Update video references in README.md
Replaced video tags with links to video assets for better accessibility.
2026-03-19 01:39:38 +08:00
bincxz
5052a8231f Improve README: mp4 demos, contributor avatars, star history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:34:00 +08:00
bincxz
13c9cf16fd Update screenshots and add demo GIFs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:26:16 +08:00
陈大猫
63558b5301 Remove HTTP localhost-only restriction for AI requests (#393)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Remove the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.

Security measures:
- Only providers explicitly configured with http:// are allowed over HTTP
- HTTPS-configured providers cannot be silently downgraded
- Temporary HTTP permissions expire after 30s TTL
- Non-http/https schemes are explicitly rejected
- webSearchApiHost entries preserved from accidental expiry

Fixes #392
2026-03-18 19:57:47 +08:00
陈大猫
c2b4d43531 Merge pull request #391 from binaricat/fix/sftp-download-windows-drive-root 2026-03-18 16:11:10 +08:00
bincxz
4d5c0eed69 Fix SFTP download failing on Windows drive root paths
On Windows, `fs.promises.mkdir("E:\", { recursive: true })` throws
EPERM for drive root directories. When users save SFTP downloads to a
drive root (e.g. E:\file.txt), `path.dirname` returns "E:\" and the
subsequent mkdir fails. Fix by catching the error and verifying the
directory already exists before re-throwing.

Fixes #390

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:06:23 +08:00
bincxz
3ad710e5da Fix AI error message wrapping
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-18 13:38:30 +08:00
陈大猫
d2e5a26317 Merge pull request #374 from yuzifu/fix-host-count-in-tree-view
Fix host count in tree view
2026-03-18 13:30:42 +08:00
陈大猫
4f1eb4a8a9 Merge pull request #389 from binaricat/codex/show-raw-ai-errors
Show raw AI errors instead of inferred causes
2026-03-18 13:26:41 +08:00
bincxz
e35bb708a2 Show raw AI errors instead of inferred causes 2026-03-18 13:00:27 +08:00
陈大猫
cd2631428e Fix AI scope leaking across tab switches (#388)
* Fix AI scope leaking across tab switches

* Keep AI executor context live across resumes
2026-03-18 11:56:28 +08:00
yuzifu
09af399543 fix: import import certificate icon size too small (#387)
fix icon small when dropdown item text is too long

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-18 10:07:07 +08:00
陈大猫
db9970d040 fix: surface streaming provider errors in chat (#386)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: surface streaming provider errors in chat

* fix: sanitize streaming status text as ByteString
2026-03-18 03:44:59 +08:00
陈大猫
3d4fbf8763 fix: keep workspace MCP scope in sync (#385)
* fix: keep workspace MCP scope in sync

* fix: refresh catty workspace tool context

* fix: preserve AI stream state across tab switches

* fix: align ACP stop and resume with 1code semantics

* fix: harden ACP resume fallback for unsupported agents
2026-03-18 03:33:00 +08:00
陈大猫
9387590696 Fix ACP stop cleanup and cancel state (#384)
* Fix ACP stop cleanup and cancel state

* Block ACP tool writes after stop

* Kill ACP child processes on cleanup

* Cleanup ACP sessions when tabs disappear
2026-03-18 02:24:36 +08:00
陈大猫
74a04f1d8e feat: three-way merge for cloud sync (#381)
Implements automatic three-way merge for cloud sync, replacing the
binary USE_REMOTE/USE_LOCAL conflict resolution. Same principle as
Git's merge algorithm.

After every successful sync, a "base snapshot" is saved (encrypted
with AES-256-GCM using the derived master key). When a conflict is
detected, the system performs per-entity merge by ID:
- Items added on one side → included
- Items deleted on one side (unchanged on other) → removed
- Items modified on one side only → take that version
- Both sides modified same item → prefer local
- One side deleted + other modified → keep modification

Additional improvements:
- Per-provider sync base to prevent cross-provider contamination
- Deep merge for nested settings (terminalSettings, customKeyBindings)
- Entity merge for array-valued settings (customTerminalThemes)
- KnownHost deduplication by (hostname, port, keyType)
- Chunked base encoding to avoid stack overflow on large vaults
- Base cleared on provider disconnect/reconnect
- Correct version numbering after multi-provider merge

Closes #378

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:12:49 +08:00
陈大猫
3c258b0f19 feat: auto-close tab when user actively exits session (#380)
* feat: auto-close tab when user actively exits a session

When a user intentionally exits a session (e.g. typing `exit`, `logout`,
or Ctrl+D), the tab is now automatically closed instead of showing the
"Start Over" disconnected page. This matches the behavior of macOS
Terminal and other popular terminal emulators.

Network errors, timeouts, and server-initiated disconnects still show
the disconnected page with the Start Over option, so users can reconnect.

In workspace mode, only the individual terminal pane is closed, not the
entire workspace.

Implementation:
- Backend bridges now include a `reason` field in exit events to
  distinguish stream-level exits ("exited") from connection errors
  ("error"), timeouts ("timeout"), and connection closes ("closed")
- SSH bridge captures real exit code from stream "exit" event instead
  of hardcoding 0
- Frontend auto-closes session only when reason is "exited"

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

* fix: address review feedback for auto-close feature

1. Pass exit event to onSessionExit in local shell path (line 757)
   to prevent undefined access when checking evt.reason

2. Change Telnet socket close reason from "exited" to "closed" since
   a clean socket close can also be server-initiated (idle timeout,
   remote shutdown), not just user exit

3. Change Serial port close reason from "exited" to "closed" since
   port close can be from device disconnect, not user action

Only SSH stream close and local/mosh process exit (node-pty onExit)
now use reason "exited", which correctly represents user-initiated exits.

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

* fix: only mark SSH exit as "exited" when stream exit event fired

ssh2's stream "close" event fires whenever the channel closes, not
only on normal shell exit. If the network drops and the channel closes
without a preceding "exit" event, the reason was incorrectly set to
"exited", causing the tab to auto-close instead of showing the
disconnected/Start Over page.

Now tracks whether stream "exit" actually fired via a flag, and only
uses reason "exited" in that case. Otherwise falls back to "closed".

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

* fix: classify mosh non-zero exits as errors

Mosh process exiting with a non-zero code typically indicates a
connection or auth failure. Mark these as reason "error" so the
disconnected/Start Over UI is shown instead of auto-closing the tab.

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

* fix: treat SSH signal-terminated exits as disconnects

ssh2's stream "exit" event also fires for signal terminations (e.g.
SIGHUP from server idle timeout, SIGTERM from admin kill), where code
is null and signal is set. These are not user-initiated exits and
should show the disconnected/Start Over page.

Now only sets streamExited=true when there's a numeric exit code and
no signal present.

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

* fix: distinguish abnormal local PTY exits from user exits

Local shell terminated by signal or crashing on startup should show
the disconnected UI, not auto-close the tab. Now only marks as
reason "exited" when exitCode is 0 and no signal, matching the same
logic used for mosh.

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

* fix: use signal presence to distinguish local shell exit reason

For local shells, non-zero exit codes are common in user-initiated
exits (e.g. typing `exit` after a failed command returns that
command's exit code). Use signal presence instead: signal means the
process was killed externally (show disconnected UI), no signal
means normal process exit (auto-close tab).

Mosh keeps exitCode-based logic since non-zero there indicates
connection/auth failure, not user exit.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:45:56 +08:00
陈大猫
6303eef3a2 fix: make global and host-level keyword highlight independent (#379) 2026-03-17 22:59:02 +08:00
yuzifu
a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +08:00
陈大猫
ccfa2d4dd0 fix: non-zero exit code is not a failure, include output on real errors (#377)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: treat non-zero exit code as success and include output on failure

- Non-zero exit codes (e.g. grep returning 1, ls on missing file) are
  valid command results, not execution failures. Changed execViaPty and
  execViaChannel to always return ok:true when the command actually ran.
- ok:false is now reserved for real failures: timeout, session gone,
  stream not writable, etc.
- When ok:false, include any partial stdout/stderr in the error message
  so the user and LLM can see what happened before the failure.

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

* fix: return stdout+exitCode for all completed commands, clean up dead code

- ptyExec: preserve original ok semantics (non-zero = ok:false) so MCP
  server bridge callers (handleMultiExec, stopOnError) still work
- execViaChannel: null exit code (SSH disconnect) returns ok:false
- toolExecutors: Catty Agent always returns stdout+exitCode to the LLM
  regardless of exit code, only treats real failures (timeout, disconnect)
  as errors — with partial output included
- Remove dead code: executeTerminalSendInput, executeSftp*, executeMultiHost
- Clean up unused imports, bridge interface, ExecutorContext

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:53:23 +08:00
陈大猫
7c5478b2a5 refactor: remove SFTP tools from AI agent (#376)
Remove sftp_list_directory, sftp_read_file, and sftp_write_file tools.
The AI can use terminal_execute with standard shell commands (ls, cat,
tee, etc.) which is more flexible, supports sudo/pipes/redirects, and
reduces tool choice complexity for the LLM.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:59:47 +08:00
陈大猫
338ba94d42 feat: add paste-only option for snippets (no auto-execute) (#375)
* feat: add "paste only" option for snippets (no auto-execute)

Add a noAutoRun flag to snippets that pastes the command into the
terminal without appending a carriage return, so users can review
and edit before manually pressing Enter.

Applies to all snippet execution paths: snippet runner (new session),
keyboard shortcut, and startup command.

Closes #371

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

* fix: use clearer wording "仅粘贴" instead of "仅上屏"

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

* fix: skip onCommandExecuted for paste-only shortcut snippets

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

* fix: persist noAutoRun on save and apply to Scripts panel clicks

- Include noAutoRun in handleSubmit serialization (was being lost)
- Pass noAutoRun through ScriptsSidePanel click handler to TerminalLayer
  so paste-only snippets work from the Scripts panel too

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:09:17 +08:00
yuzifu
1d4ec7afb9 Merge remote-tracking branch 'origin/fix-host-count-in-tree-view' into fix-host-count-in-tree-view 2026-03-17 17:25:00 +08:00
yuzifu
a1899951e0 fix: show hosts count(update)
Avoid recalculating the number of hosts during re-rendering
2026-03-17 17:24:16 +08:00
陈大猫
b7b2e91fab fix: show real error message instead of [object Object] (#373)
* fix: show real error message instead of [object Object]

When an error object (not a string or Error instance) reaches the
error display path, String(obj) produces "[object Object]". Now
extract .message from error-like objects, or JSON.stringify as fallback.

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

* fix: guard JSON.stringify fallback against undefined return

JSON.stringify(undefined) returns undefined (not a string), which would
crash classifyError().toLowerCase(). Add ?? 'Unknown error' fallback.

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

* fix: use non-throwing fallback for error serialization

JSON.stringify can throw on circular objects or BigInt values. Wrap in
try-catch to avoid losing the original error and leaving the stream
stuck in a streaming state.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:23:05 +08:00
yuzifu
cd723000fc fix: show host count in tree view (#364)
* fix: show host count in tree view

* update show host count in tree view

* perf: memoize subtree host count to avoid repeated traversals

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

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:00:31 +08:00
bincxz
d84668aa0f perf: memoize subtree host count to avoid repeated traversals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:41 +08:00
yuzifu
68d0f4574c update show hosts count in tree view 2026-03-17 16:40:45 +08:00
陈大猫
fff031eb25 fix: remove multi_host_execute and fix MissingToolResultsError (#372)
Remove multi_host_execute tool — the AI can call terminal_execute for
each host individually, which is simpler, more reliable, and avoids
the hang issue where parallel remote commands block the stream.

Fix AI_MissingToolResultsError that occurs after user stops a stream
mid-tool-execution: when building SDK messages, skip orphaned tool
calls that have no matching tool result instead of including them
(which causes the SDK to reject the next message).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:29:57 +08:00
yuzifu
2f1fd399cf fix: avoid repeated sync (#370)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-17 16:17:04 +08:00
陈大猫
43c4d4c430 fix: open settings window on the same display as the main window (#367)
Use Electron's screen.getDisplayMatching() to find which display the
main window is on, then center the settings window on that display's
work area. Previously the settings window used Electron's default
placement which could open on the primary display even when the main
window was on an external monitor.

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:35 +08:00
陈大猫
835a1231a6 feat: add skip TLS verification option for self-hosted AI providers (#369)
* feat: add skip TLS verification option for AI providers

Self-hosted AI endpoints (vLLM, text-generation-webui, etc.) often use
self-signed TLS certificates which Node.js rejects by default, causing
502 Bad Gateway errors. Add a per-provider "Skip TLS certificate
verification" checkbox that sets rejectUnauthorized=false on both
streaming and non-streaming requests.

Ref #294

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

* fix: surface real error message instead of generic 502 Bad Gateway

- Pass the actual bridge error message in statusText so Vercel AI SDK
  shows the real cause (e.g. "HTTP is only allowed for localhost",
  "URL host is not in the allowed list", TLS errors)
- Show real error details for 5xx provider errors instead of generic
  "The AI provider returned a server error" message

Previously all connection-level errors were masked as "Bad Gateway"
making it impossible for users to diagnose configuration issues.

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

* fix: pass server error body details through to the user

- Read HTTP error response body before resolving (was resolving before
  body was read, losing the error detail)
- Parse OpenAI-compatible JSON error format to extract error.message
- Return error Response with body+statusText for non-2xx instead of
  empty stream, so Vercel AI SDK shows the real server error
- Now users see e.g. "502 model not loaded" instead of just "Bad Gateway"

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

* fix: widen link modifier key dropdown to prevent text wrapping

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

* Revert "fix: widen link modifier key dropdown to prevent text wrapping"

This reverts commit 1f756863910d7450c6ffd8c373ef156e90adcce7.

* fix: apply skipTLSVerify to model listing requests

ModelSelector.aiFetch() didn't pass providerId, so the provider-level
skipTLSVerify was not applied when refreshing/listing models. Add
skipTLSVerify as a direct parameter alongside the provider lookup.

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

* fix: keep error detail in Response body, not statusText

statusText only accepts single-line Latin-1 — multiline or non-ASCII
error messages from self-hosted gateways would throw TypeError before
the AI SDK could read them. Move detailed error to body instead.

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

* fix: return JSON error body for AI SDK compatibility, fix FetchBridge type

- Wrap error responses in OpenAI-compatible JSON format so Vercel AI
  SDK's failedResponseHandler extracts the message correctly instead
  of showing a blank error
- Update FetchBridge type to match the expanded aiFetch parameter list

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

* fix: add ASCII statusText fallback for non-OpenAI SDK providers

Anthropic/Google SDKs fall back to Response.statusText when they can't
parse the error body. Add safe ASCII statusText alongside the JSON body.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:09 +08:00
陈大猫
cd512d0800 fix: host-level keyword highlight toggle now overrides global setting (#368)
When a host explicitly disables keyword highlighting, global rules are
no longer applied to that terminal. Previously the OR logic
(globalEnabled || hostEnabled) meant per-host disable had no effect
when global highlighting was enabled.

Now: hostEnabled=false suppresses global rules; hostEnabled=undefined
inherits global setting (backward compatible).

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:38:59 +08:00
陈大猫
0c5ae13692 fix: widen settings dropdown selects to prevent text wrapping (#366)
Log Format "Plain Text (.txt)" and Link modifier key "None (click
directly)" were wrapping to two lines due to narrow widths.

Closes #294 (dropdown text wrapping)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:36:14 +08:00
陈大猫
6727248924 feat: add web search & URL fetch tools for AI agent (#365)
* feat: add web search and URL fetch tools for AI agent

Add web_search and url_fetch tools to Catty Agent, allowing the AI to
search the internet for current information and fetch webpage content.

- Support 5 search providers: Tavily, Exa, Bocha, Zhipu, SearXNG
- Settings UI with provider selection, API key encryption, and config
- web_search is conditional on config; url_fetch is always available
- Both tools are read-only and work in all permission modes (incl. observer)
- aiFetch skipHostCheck for AI tool requests to arbitrary URLs
- System prompt guidelines for when to use search/fetch
- i18n support (en + zh-CN)

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

* fix: address code review findings (SSRF, key exposure, state race)

- P1: Restore SSRF protection when skipHostCheck is true — still block
  localhost, RFC1918, link-local, and cloud metadata endpoints; only
  skip the domain allowlist for public HTTPS hosts
- P2: Move web search API key decryption to main process via dedicated
  IPC handler, matching the existing provider key security model
- P2: Use configRef to avoid stale closure in async settings callbacks
  that could overwrite newer user changes

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

* fix: address second review — DNS rebinding, url_fetch approval, maxResults

- P1: url_fetch now requires approval in confirm mode (outbound GET is
  a side effect that could exfiltrate data via query strings)
- P1: Add DNS resolution check when skipHostCheck is set — resolve
  hostname and reject if any IP is private/loopback/link-local, blocking
  DNS rebinding attacks against internal services
- P2: Slice search results after provider call to enforce maxResults
  consistently (Zhipu and SearXNG ignore the limit parameter)

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

* fix: address third review — localhost/IPv6 SSRF, API key blur race

- P1: Block localhost/loopback when skipHostCheck is enabled — restructure
  isAllowedFetchUrl to check private hosts first in the skipHostCheck path,
  preventing access to local services on allowlisted ports
- P1: Handle IPv6 private ranges (fc00::/7, fe80::/10, ::ffff: mapped),
  strip brackets from URL.hostname, block [::1] and fd00:: addresses
- P2: Guard handleApiKeyBlur against provider change during async
  encryption — skip stale write if provider switched while encrypting

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

* fix: address fourth review — main-process key isolation, SearXNG compat

- P1: Replace aiWebSearchDecryptKey IPC with __WEB_SEARCH_KEY__ placeholder
  pattern — renderer never sees plaintext keys; main process replaces
  placeholder in headers before HTTP request, matching provider key flow
- P1: Search API requests use normal allowlist path (not skipHostCheck),
  so SearXNG on localhost/HTTP/private networks works via aiSyncWebSearch;
  only url_fetch uses skipHostCheck for arbitrary public HTTPS URLs
- P2: Remove needsApproval from url_fetch — treat as read-only like
  sftp_read_file, consistent with observer mode allowlist

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

* fix: address fifth review — private LAN providers, maxResults default

- P1: Allow private-IP hosts that are explicitly in the provider/search
  allowlist (e.g. https://192.168.x.x model providers or SearXNG)
- P2: Remove .default(5) from web_search maxResults schema so the user's
  configured maxResults setting is used when the model omits the param

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

* fix: address sixth review — HTTPS scope, config gate, redirects

- P2: Scope HTTP exception to private/LAN IPs only — remote allowlisted
  hosts still require HTTPS to protect API keys in transit
- P2: Gate web_search tool on complete config (API key for providers that
  require it, apiHost for SearXNG) to avoid advertising a broken tool
- P2: Add redirect following (up to 5 hops) to aiFetch for url_fetch —
  handles 301/302/307 for short links, www canonicalization, etc.

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

* fix: address seventh review — redirect SSRF, decrypt race, HTTPS-only

- P1: Revalidate each redirect hop against SSRF guards (allowlist check
  + DNS resolution) before following, preventing open-redirect SSRF
- P2: Add sequence counter to API key decryption effect — stale promise
  results from a previous provider are discarded on provider switch
- P3: Restrict url_fetch to HTTPS-only URLs, matching the skipHostCheck
  policy that already rejects HTTP in the bridge

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

* fix: address eighth review — OS resolver, allowlisted HTTP hosts

- P1: Use dns.lookup (OS resolver) instead of dns.resolve4/6 for private
  IP checks — matches what http.request actually connects to, respects
  /etc/hosts, mDNS, and other local resolver sources
- P2: Allow HTTP for any explicitly allowlisted host (not just literal
  private IPs), so self-hosted SearXNG at http://searxng.lan works

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

* fix: address ninth review — HTTP scope, blur ordering, decrypt flag

- P1: Narrow HTTP exception to web search apiHost only — AI provider
  endpoints remain HTTPS-only to protect credentials in transit
- P2: Add blur sequence counter to prevent out-of-order encryption
  results from overwriting newer API key saves
- P2: Reset isDecrypting flag when cancelling decrypt on provider switch,
  preventing permanently disabled API key input

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

* fix: address tenth review — DNS pinning, prompt/tool alignment

- P1: Pin validated DNS result to the HTTP request via custom lookup
  function, preventing TOCTOU/DNS-rebinding between validation and
  actual connection
- P2: Extract isWebSearchReady() helper and use it consistently in
  both tool registration and system prompt, so the model isn't told
  web search is available when config is incomplete

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

* fix: address eleventh review — single DNS lookup, redirect pinning, CGNAT

- P1: Combine DNS validation and pinning into a single lookup call,
  eliminating the TOCTOU window between hasPrivateResolution and pinnedLookup
- P1: Pin DNS for redirect targets too — resolve/validate/pin in one step
  before following each redirect hop
- P2: Add 100.64.0.0/10 (CGNAT) to private IP ranges for Tailscale and
  similar CGNAT-addressed internal services

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

* fix: address twelfth review — apiHost validation, sync on enable

- P2: Validate apiHost is a well-formed URL in isWebSearchReady(),
  preventing tool exposure when user enters a malformed host
- P2: Add webSearchConfig.enabled to sync effect deps so the main
  process gets updated immediately when the toggle changes

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

* fix: remove DNS-level SSRF checks that break fakedns/proxy environments

DNS resolution validation (dns.lookup + IP pinning) breaks in proxy
environments where fakedns resolves all domains to LAN addresses.
Revert to hostname-level checks only (blocking localhost, 127.0.0.1,
metadata endpoints, etc.) which are sufficient without false positives.

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

* fix: resolve empty catch block lint warning

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:19:29 +08:00
yuzifu
bedf59bb48 update show host count in tree view 2026-03-17 10:17:57 +08:00
yuzifu
793ea94078 fix: show host count in tree view 2026-03-17 09:16:01 +08:00
陈大猫
0eee7bf95a Merge pull request #363 from binaricat/feat/osc52-clipboard
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add OSC-52 clipboard support
2026-03-16 22:04:39 +08:00
bincxz
b2406ec8a5 fix: auto-reject OSC-52 prompt for hidden tabs and restore focus
- Reject clipboard read requests when terminal is not visible (background
  tab), preventing invisible prompts that block remote programs
- Restore terminal focus after user responds to the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:53:52 +08:00
bincxz
5fde9c2d61 fix: improve OSC-52 prompt UX
- Reject concurrent read requests instead of overwriting resolver
- Add autoFocus to Allow button for keyboard accessibility
- Support Escape key to deny the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:49:47 +08:00
bincxz
06a6a0ac12 feat: add 'prompt' mode for OSC-52 clipboard reads
Add a fourth option 'Write + Prompt on Read' that allows clipboard
writes but shows a confirmation dialog before granting read access.
This lets users benefit from remote copy (tmux/vim) while maintaining
control over clipboard reads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:42:22 +08:00
bincxz
024e60ead1 fix: reject unsupported OSC-52 selection targets
Only handle clipboard target ('c'); silently ignore unsupported targets
like 'p' (PRIMARY selection) which Electron cannot access, rather than
incorrectly mapping them to the system clipboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:24:49 +08:00
bincxz
fe71790f0a fix: add osc52Clipboard to syncable terminal settings
Ensures the OSC-52 clipboard preference is preserved across cloud sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:18:54 +08:00
bincxz
9371b3d01b fix: use Electron bridge for OSC-52 read and chunk base64 encoding
- Fall back to netcattyBridge.readClipboardText() for clipboard reads
  since navigator.clipboard.readText() may be unavailable in Electron
- Chunk String.fromCharCode() calls in 8KB batches to avoid stack
  overflow on large clipboard contents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:14:25 +08:00
bincxz
5a1d279efd fix: add OSC-52 settings, UTF-8 support, and clipboard read
- Add osc52Clipboard setting (off/write-only/read-write), default write-only
- Fix UTF-8 decoding: use TextDecoder instead of atob for non-ASCII content
- Support clipboard read requests when mode is read-write
- Add settings UI with Select dropdown and i18n (en + zh-CN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:08:11 +08:00
bincxz
8b0cbf02c3 feat: add OSC-52 clipboard support for terminal
Register an OSC-52 handler on the xterm parser to allow remote programs
(e.g. tmux, vim, neovim) to write to the local system clipboard via
escape sequences. Read requests are ignored for security.

Closes #362

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:52:29 +08:00
陈大猫
d19fe45a14 Merge pull request #361 from binaricat/fix/win-ssh-agent-pipe-detect
fix: use net.connect() for Windows SSH agent pipe detection
2026-03-16 20:40:26 +08:00
bincxz
344946b096 fix: use net.connect() for Windows SSH agent pipe detection
fs.statSync() is unreliable for Windows named pipes — it returns EBUSY
even when the pipe is fully usable, causing ssh-agent to appear
unavailable. Replaced with net.connect() which is the authoritative
check for named pipe connectivity.

Fixes #360

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:33:58 +08:00
陈大猫
fcd15707d2 Merge pull request #359 from binaricat/fix/auth-split-button
fix: split auth button for clear save/no-save options
2026-03-16 20:07:46 +08:00
bincxz
42c82e46ea fix: split auth button so "continue without save" is clearly separated
The auth dialog's "Continue and Save" button had a dropdown arrow embedded
inside it, but clicking anywhere on the button (including the arrow)
triggered save. Users expected the arrow to offer a no-save option but
couldn't discover it. Refactored to a proper split button: left side
triggers "Continue and Save", right arrow opens a dropdown with
"Continue" (without saving).

Refs #356

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:55:04 +08:00
陈大猫
0e1c3b621a Merge pull request #358 from binaricat/fix/snippet-package-rename
fix: snippet package rename losing snippets and blocking case changes
2026-03-16 19:45:31 +08:00
bincxz
3cd3bbaaf7 fix: snippet package rename losing snippets and blocking case changes
Two bugs in snippet package management:

1. Renaming a package with only case changes (e.g. Speedtest → speedtest)
   was rejected as duplicate because the case-insensitive check didn't
   exclude the package being renamed.

2. Renaming/moving/deleting a package caused its snippets to disappear
   because forEach(onSave) called the state updater multiple times with
   a stale closure, each call overwriting the previous. Only the last
   snippet's update survived. Fixed by adding onBulkSave prop that
   passes the entire updated array in one call.

Fixes #357

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:41:27 +08:00
陈大猫
8bfb50fcbb Merge pull request #355 from yuzifu/fix-distro-detect
fix distro detect
2026-03-16 19:30:54 +08:00
bincxz
c39ef879c3 fix: use effective passphrase for distro detection probe
The distro detection was using the stored key passphrase instead of the
runtime-resolved passphrase, causing silent failures when users retry
with a manually entered passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:22:20 +08:00
陈大猫
b3d5785477 fix: allow settings window as trusted IPC sender (#354)
* fix: allow settings window as trusted IPC sender

The settings window runs in a separate BrowserWindow with its own
webContents id. validateSender() only checked the main window id,
causing "Unauthorized IPC sender" errors when fetching AI model
lists from the settings page.

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

* fix: add validateSender to all remaining AI IPC handlers

15 handlers in aiBridge were missing sender validation, allowing
potential unauthorized IPC calls. Now every netcatty:ai:* handler
consistently validates the sender against trusted windows.

Affected handlers: chat:cancel, agents:discover, resolve-cli,
codex:get-integration, codex:start-login, codex:get-login-session,
codex:cancel-login, codex:logout, mcp:update-sessions,
mcp:set-command-blocklist, mcp:set-command-timeout,
mcp:set-max-iterations, mcp:set-permission-mode, acp:cancel,
acp:cleanup.

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

* fix: scope settings window trust to config-only IPC handlers

Per code review feedback: the previous commit allowed the settings
window to access ALL AI IPC handlers including high-risk ones like
exec, terminal:write, and agent:spawn.

Split into two validators:
- validateSender(): main window only (exec, terminal, agent, stream)
- validateSenderOrSettings(): main + settings (fetch, sync, codex
  login, MCP config, agent discovery)

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

* fix: refresh main window id on recreation and allow settings fetch

Two fixes from code review:

1. Always resolve mainWebContentsId from windowManager instead of
   caching it once, so a recreated main window is recognized.

2. Skip static host allowlist for settings window ai:fetch calls,
   since the settings UI lets users configure custom provider URLs
   that haven't been synced to providerFetchHosts yet. Basic URL
   safety (HTTPS-only, no file:// schemes) is still enforced.

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

* fix: enforce HTTPS/port safety for settings window fetch requests

Per review: previous commit skipped isAllowedFetchUrl entirely for
settings window, which removed SSRF protection. Now settings window
fetches still bypass the static host allowlist (since the user is
configuring new providers) but enforce the same safety rules:
- Remote hosts must use HTTPS
- Localhost must use known ports

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

* fix: sync provider config before fetching models in settings

Instead of bypassing the URL allowlist for settings window fetches
(which weakens SSRF protection), have ModelSelector sync the current
provider's baseURL to the backend allowlist before fetching models.
This keeps the full URL safety checks intact while allowing settings
to test custom provider endpoints.

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

* fix: use dedicated allowlist handler instead of syncing providers

Replace the approach of calling aiSyncProviders (which overwrites
the shared providerConfigs) with a new lightweight IPC handler
netcatty:ai:allowlist:add-host that only adds a host to the fetch
allowlist without affecting provider configs or API key resolution.

This preserves the SSRF protection while allowing settings to test
custom provider URLs that haven't been synced from the main window.

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

* fix: auto-expire temporary allowlist entries after 30 seconds

Temporary hosts added via allowlist:add-host now auto-remove after
30s to prevent permanently expanding the SSRF boundary. Built-in
ports and hosts re-added by provider sync are preserved.

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

* fix: prevent temp allowlist cleanup from removing synced providers

The setTimeout cleanup now checks whether the host/port belongs to
a currently synced provider config before removing it. This prevents
the scenario where a user saves a provider within the 30s TTL window
and then loses access when the timer fires.

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

* fix: preserve temp allowlist entries across provider sync rebuilds

rebuildProviderFetchHosts() clears and rebuilds the allowlist from
providerConfigs, which would wipe temporary entries added by
allowlist:add-host. Now re-adds active temp entries after rebuild
to prevent race conditions between settings model listing and
provider sync from the main window.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:11:42 +08:00
yuzifu
05de49f7da fix distro detect
Support distro detection with passphrase keys
2026-03-16 17:32:33 +08:00
bincxz
f77c2b2de9 fix: resolve ESLint errors blocking dev startup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Add release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:09:00 +08:00
陈大猫
f79f27d737 feat: add settings cloud sync support (#353)
* feat: add settings cloud sync support (closes #347)

Expand SyncPayload.settings to include all syncable user preferences
(theme, appearance, terminal, keyboard, editor, SFTP). Add
collectSyncableSettings/applySyncableSettings helpers in syncPayload.ts,
wire rehydrateAllFromStorage through App.tsx and SettingsPage.tsx so
in-memory React state updates after a cloud download.

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

* fix: include settings in auto-sync uploads and sync empty customCSS

P1: useAutoSync.buildPayload now includes collectSyncableSettings()
so settings are uploaded alongside vault data.

P2: customCSS uses != null check instead of truthy, so clearing CSS
on one device is properly synced to others.

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

* fix: include settings in auto-sync change detection hash

Settings-only changes (theme, terminal options, etc.) now trigger
auto-sync uploads. The data hash comparison includes the settings
snapshot alongside vault data.

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

* fix: trigger auto-sync on settings changes and sync custom terminal themes

P1: Added settingsVersion (derived from all synced settings via useMemo)
to useAutoSync debounce effect dependencies. Settings-only changes now
trigger auto-sync uploads.

P2: Custom terminal themes (STORAGE_KEY_CUSTOM_THEMES) are now included
in the sync payload so custom themes are available on other devices.

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

* fix: reload custom theme store after sync, include in change detection

P1: customThemeStore.loadFromStorage() is now called in
rehydrateAllFromStorage so synced custom themes are immediately
reflected in the live theme store.

P2a: customThemes added to settingsVersion dependencies so custom
theme edits trigger auto-sync.

P2b: Empty custom themes array is now preserved in sync payload
to properly propagate theme deletion.

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

* fix: notify subscribers after custom theme store reload

loadFromStorage now calls notify() to trigger useSyncExternalStore
subscribers, so synced custom terminal themes are immediately
visible in all windows after apply.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:57:41 +08:00
陈大猫
ec35daa0dd feat: add auto-update toggle setting (#351)
* feat: add auto-update toggle setting (closes #346)

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

* fix: re-check auto-update toggle when startup timer fires

Address review feedback: the startup check effect now re-reads the
toggle from localStorage when the delayed timer fires, so toggling
off after launch cancels the pending check. Also avoids setting
hasCheckedOnStartupRef when disabled, allowing re-enable to trigger
a check without restart.

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

* fix: address review feedback on auto-update toggle

P1: When autoDownload=false, onUpdateAvailable no longer transitions
to 'downloading' status. Instead keeps autoDownloadStatus idle so
the manual download link surfaces correctly.

P2: Added reactive autoUpdateEnabled state (synced via storage event)
as a dependency to the startup check effect. Re-enabling the toggle
mid-session now re-triggers the deferred startup check.

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

* fix: address P1/P2 review feedback on auto-update toggle

P1: Main process update-available handler now checks updater.autoDownload
before setting _lastStatus to 'downloading'. When autoDownload=false,
status stays 'idle' so late-opened windows don't hydrate to a stuck
0% download state.

P2: useUpdateCheck now accepts autoUpdateEnabled as a prop from the
caller instead of relying solely on storage events (which don't fire
in the same window). SettingsPage passes settings.autoUpdateEnabled
directly, so toggling in the current window takes effect immediately.

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

* fix: preserve update-available info for late-opening windows

When autoDownload is off, use status 'available' (instead of 'idle')
in the main process snapshot so late-opening windows can hydrate
version info. The renderer maps 'available' to hasUpdate=true while
keeping autoDownloadStatus='idle' for the manual download path.

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

* fix: re-schedule auto-check on re-enable and guard startup timer

- IPC handler now calls startAutoCheck(2000) when re-enabling so the
  user gets automatic checks without restarting the app.
- startAutoCheck timer checks updater.autoDownload at fire time, so
  if the renderer disables auto-update via IPC before the 5s startup
  timer fires, the check is skipped.

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

* fix: deduplicate auto-check scheduling and clear error on fallback success

P1: startAutoCheck now cancels any existing timer before scheduling
a new one, preventing duplicate concurrent checks from multiple
windows or re-enable toggles.

P2: checkNow fallback now clears manualCheckStatus='error' when
electron-updater successfully finds an update (res.available=true),
so the UI shows 'available' instead of a stale error state.

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

* fix: only reschedule on actual re-enable and hydrate cache before toggle check

P2: Track previous autoDownload state in IPC handler so startAutoCheck
is only called on actual false→true transitions, not on every window
mount that syncs the current value.

P3: Move cache hydration (STORAGE_KEY_UPDATE_LATEST_RELEASE) before
the auto-update toggle check so cached update info is always visible
even when automatic updates are disabled.

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

* fix: persist auto-update preference in main process across restarts

Read/write auto-update preference to a JSON file in userData so the
main process honors it on next launch without waiting for renderer IPC.
getAutoUpdater() now initializes autoDownload from the persisted value.

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

* fix: suppress cached update toast when disabled and update IPC types

P2: Cache hydration now gates hasUpdate on autoUpdateEnabled so the
App.tsx toast doesn't fire when automatic updates are disabled.

P3: Updated global.d.ts to include 'available' in getUpdateStatus
status union and 'checking' in checkForUpdate return type.

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

* fix: preserve dismissed releases, show cached updates in Settings, guard concurrent checks

P2a: Updater fallback now checks STORAGE_KEY_UPDATE_DISMISSED_VERSION
before re-surfacing a release found by electron-updater.

P2b: Cache hydration always sets hasUpdate truthfully so Settings
shows the available update. Toast suppression for disabled auto-update
moved to App.tsx (reads localStorage directly).

P3: Re-enable IPC handler checks _isChecking before scheduling
startAutoCheck to prevent concurrent electron-updater calls.

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

* fix: use localStorageAdapter for lint compliance, skip IPC on initial mount

P1: Replace direct localStorage access with localStorageAdapter in
App.tsx toast guard to fix no-restricted-globals lint error.

P2: Skip setAutoUpdate IPC on initial mount to prevent overwriting
the main-process preference file when renderer localStorage has been
cleared (where the default would be true).

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

* fix: hydrate auto-update state from main-process preference on mount

Add getAutoUpdate IPC handler so the renderer can query the persisted
preference from auto-update-pref.json. On mount, useSettingsState
reconciles localStorage with the main-process truth, preventing the
toggle from showing 'enabled' when the user had previously disabled
it and localStorage was cleared.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:54:40 +08:00
陈大猫
ed0775d9d2 Merge pull request #352 from binaricat/feat/global-hotkey-toggle
feat: add global hotkey enable/disable toggle
2026-03-16 12:41:54 +08:00
bincxz
1f31629ce0 feat: add global hotkey enable/disable toggle (closes #349)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:36:37 +08:00
陈大猫
cc4a904dea Merge pull request #350 from binaricat/fix/gemini-empty-function-response-name
fix: resolve Gemini API error caused by empty functionResponse name
2026-03-16 11:56:57 +08:00
bincxz
e9e1d87ff5 fix: resolve Gemini API error caused by empty functionResponse name
When rebuilding SDK messages from conversation history, tool-result
messages had toolName hardcoded to an empty string. This works for
OpenAI/Claude APIs but Gemini requires functionResponse.name to be
non-empty, causing AI_APICallError on every follow-up message.

Now looks up the tool name from the matching assistant tool call
via toolCallId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:43:28 +08:00
陈大猫
a6b07f39ad Merge pull request #348 from yuzifu/fix-dropdown-lists-height
enable scrollbar in dropdown lists when content exceeds max-height
2026-03-16 11:23:36 +08:00
yuzifu
6892e11952 enable scrollbar in dropdown lists when content exceeds max-height 2026-03-16 11:07:56 +08:00
bincxz
ec9be922cb fix: unpack MCP server transitive dependencies from asar
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The MCP server runs as a standalone Node process (not Electron), so it
cannot access modules inside app.asar. Add missing transitive deps
(zod-to-json-schema, ajv, ajv-formats, fast-deep-equal, fast-uri,
json-schema-traverse) to asarUnpack so they are available on disk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:10:56 +08:00
陈大猫
6e961b0efd Merge pull request #345 from binaricat/fix/upgrade-node-pty
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: upgrade node-pty to 1.1.0 stable (#264)
2026-03-16 01:52:13 +08:00
bincxz
d3fe2f9f53 ci: pin Linux x64 build to ubuntu-22.04 for broader glibc compatibility
ubuntu-latest (24.04) links native modules against glibc 2.39 which can
cause dlopen failures on some distros. Pin to 22.04 (glibc 2.35) for
wider compatibility across Linux distributions.

Related: #264

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:51:45 +08:00
bincxz
88760b763e fix: upgrade node-pty from 1.1.0-beta19 to 1.1.0 stable
The beta version had native module loading issues on Arch Linux AppImage
builds (ERR_DLOPEN_FAILED). The stable release uses an improved module
loading strategy with prebuild support for macOS/Windows and better
build-from-source fallback for Linux.

Related: #264

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:50:39 +08:00
陈大猫
6dfe543ab5 Merge pull request #344 from binaricat/fix/windows-ssh-agent-pipe-detection
fix: detect SSH agent via named pipe instead of service status (#343)
2026-03-16 01:41:40 +08:00
bincxz
c000996cb4 fix: detect SSH agent via named pipe instead of service status on Windows
Previously, Netcatty checked if the OpenSSH Authentication Agent Windows
service was running via `sc query ssh-agent`. This broke compatibility
with third-party SSH agents (Bitwarden, 1Password, gpg-agent) that
provide the same named pipe without running the system service.

Now we probe `\\.\pipe\openssh-ssh-agent` directly with fs.statSync,
which works regardless of which agent provides the pipe.

Fixes #343

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:40:05 +08:00
陈大猫
f70b604996 Merge pull request #340 from binaricat/feat/ai-chat-panel
feat: AI chat panel with Vercel AI SDK
2026-03-16 01:35:15 +08:00
bincxz
b973382f9f fix: return real HTTP status in streaming bridge and dynamic provider URL allowlist
- streamRequest now resolves on headers arrival with statusCode/statusText
  so the renderer constructs Response with the real HTTP status (e.g. 401)
  instead of hardcoded 200
- Provider fetch URL allowlist is now dynamically rebuilt from configured
  provider baseURLs on sync, supporting custom provider endpoints
- Localhost port allowlist properly resets on provider sync (no stale ports)
- PTY marker detection requires line-boundary match to avoid false positives
- Clarify terminal_send_input vs terminal_execute usage in tool descriptions
  and system prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:34:35 +08:00
bincxz
eeb300295d refactor: improve security, accessibility, performance, and code organization
- Security: API keys no longer transit IPC as plaintext; renderer sends
  providerId and main process decrypts via safeStorage. Add ReDoS
  protection for user-supplied blocklist regex. Sanitize error messages
  to strip file paths and sensitive URLs before displaying in chat.
- Bug fixes: approval timeout now notifies user in chat instead of
  silently aborting. statusText cleared consistently across all 7 code
  paths. useAIState persistence race condition fixed with mountedRef
  guard, storage sync validated with type checks.
- Accessibility: InlineApprovalCard uses role="alertdialog" with focus
  on approve button. ChatInput menus have proper ARIA roles, labels,
  and aria-expanded. ThinkingBlock toggle has aria-expanded/controls.
  Model selector submenu supports keyboard navigation.
- UX: switching agents preserves old session and creates new one.
  Approval card buttons disabled immediately after click.
- Performance: text-delta streaming batched via requestAnimationFrame.
- Refactor: extract useAIChatStreaming, useToolApproval, and
  useConversationExport hooks from AIChatSidePanel (1514 → 751 lines).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:51:23 +08:00
bincxz
be36ccd167 fix: address security, correctness, and performance issues from code review
Security: add validateSender to agent IPC handlers, remove CODEX_API_KEY
and NODE_PATH from SAFE_ENV_KEYS, validate URL scheme before openExternal,
add backtick substitution to command blocklist, add 10MB buffer limits.

Bugs: fix runExternalAgentTurn timeout hang, fix limitConcurrency undefined
entries on error, treat null exitCode as failure, enforce maxBytes for SFTP reads.

Performance: debounce addMessageToSession persistence, cache compiled
blocklist regexes, add QuotaExceededError handling for localStorage writes.

UI: use local onKeyDown for PermissionDialog, move CRITICAL_PATHS to module level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:18:45 +08:00
bincxz
71b13a77a3 fix: address security, correctness, and performance issues from code review
Security: validate agent spawn commands against allowlist, replace execSync
with execFileSync in agent discovery, restrict localhost SSRF to known ports,
add observer mode enforcement at IPC layer, add IPC sender validation,
expand dangerous env key blocklist, add pending approval timeout.

Correctness: replace updateLastMessage with ID-based updateMessageById to
fix race condition during streaming, include tool-call/tool-result messages
in SDK context, flush debounced persist on unmount, fix ACP client break
placement, use proper AI SDK message types.

Performance: pre-compile command blocklist regexps, extract shared tool
executors to deduplicate executor.ts and tools.ts (~50% reduction each).

Also: i18n for hardcoded English strings, remove dead detectDoomLoop code,
add bypass-resistant blocklist patterns, include permissionMode in ACP
provider reuse fingerprint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:10:17 +08:00
bincxz
808d021ebe fix: address security, correctness, and performance issues from code review
- Use crypto.randomBytes for PTY markers instead of Math.random
- Replace execSync with execFileSync to prevent command injection
- Fix sftp_write_file missing safety check (pass path to guardWriteOperation)
- Add IPC input validation for handleExec, handleSftpRead, handleMultiExec
- Add maxBytes bounds validation in executor, tools, and MCP server
- Fix approval race condition (clear ref after destructuring, strict messageId match)
- Shorten API key plaintext lifetime in memory
- Fix stream cancellation race with AbortController registered before request
- Delay revokeObjectURL for download timing
- Extract shared limitConcurrency to infrastructure/ai/concurrency.ts
- Add execStream null check in execViaChannel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:46:13 +08:00
bincxz
d03117733d fix: clear statusText on tool-call and tool-result events
The "Waiting for response from agent..." status text was persisting
after tool completion because only onTextDelta cleared statusText.
When an agent went directly from tool-result to a new tool-call
without emitting text, the stale statusText remained visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:34:10 +08:00
bincxz
1816c3d0df fix: resolve eslint warnings in AI chat panel components
- Copy abortControllersRef to variable before cleanup
- Remove unnecessary useMemo deps (activeTabId, workspaces)
- Remove unused Shield import
- Add missing deps (t, isCustom) to useMemo/useCallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:28:50 +08:00
bincxz
b192ee1764 fix: address security, correctness, and performance issues from code review
Security: sanitize command input in resolveCliFromPath, add host allowlist
to streaming endpoint, enforce permission model in MCP server tools, add
safety check to terminal:write IPC, fix broken blocklist regex, remove
renderer-controlled allowedHosts parameter.

Correctness: use sessionsRef for latest state in handleSend, merge
add+update to avoid race condition in streaming, mark assistant message
completed after tool-result, return JSON-RPC error for unhandled ACP
permission requests, add finished guard in ptyExec.

Performance: custom React.memo comparator for ChatMessageList, fix doom
loop detection threshold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:27:47 +08:00
bincxz
0b9cb86c4e fix: address security and correctness issues from code review
- Add command blocklist check to terminal_send_input (executor + SDK tools)
- Add session scope validation to all tools in executor.ts
- Fix abort handler to call aiChatCancel instead of aiChatStream
- Enforce URL allowlist in fetch proxy to prevent SSRF
- Wrap event.sender.send with safeSend for destroyed window check
- Filter dangerous env vars (LD_PRELOAD, NODE_OPTIONS, etc.) from agent spawn
- Fix stale debouncedPersistSessions closure using sessionsRef
- Fix cleanupOrphanedSessions race when sessions load before workspaces
- Fix limitConcurrency implementation using Set + finally pattern
- Improve command blocklist regex patterns with word boundaries
- Add regex validation with error feedback in SafetySettings
- Add confirmation dialog for provider removal
- Fix React key warning for tool-role messages in ChatMessageList
- Remove debug console.warn in TerminalLayer
- Remove unused nanoid dependency
- Remove redundant platform-specific codex-acp binary from dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:10:51 +08:00
bincxz
bcd44f0177 refactor: remove Claude Agent SDK integration in favor of ACP
All external agents now use ACP protocol exclusively. The Claude Agent
SDK flow was fully implemented but never wired into the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:53:37 +08:00
bincxz
d8d29d1709 fix: address code review findings for AI chat panel
Security:
- Apply checkCommandSafety() in aiExec IPC handler to enforce command blocklist
- Add critical path validation in handleSftpRemove (normalize + blocklist)
- Change rm -rf to rm -r so permission errors surface

Stream lifecycle:
- Abort all active streams on component unmount (abortControllersRef cleanup)
- Wrap stream reader in try/finally with releaseLock() to prevent leaks
- Use refs for inputValue/images in handleSend to stabilize callback identity

State persistence:
- Clear debounce timer before synchronous persist in destructive operations
  (clearSessionMessages, deleteSession, deleteSessionsByTarget)
- Add 10MB max buffer guard in ACP client and MCP server NDJSON parsing

i18n:
- Replace hardcoded English strings with t() calls in InlineApprovalCard,
  PermissionDialog, ConversationExport, ThinkingBlock, AgentSelector
- Add 23 new i18n keys to en.ts and zh-CN.ts

Misc:
- Remove debug console.log statements in mcpServerBridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:38:03 +08:00
bincxz
0820569166 fix: use session ID in approval finally block for streaming cleanup
The finally block in handleApprovalResponse still used scope key (sk)
instead of session ID (sid) for setStreamingForScope and abort controller
cleanup, causing the streaming indicator to not clear after approval flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:09:28 +08:00
bincxz
545506ac86 fix: track streaming state per session instead of per scope
When switching agents or creating a new chat within the same scope,
the stop button stayed red because streaming was keyed by scopeKey
(which doesn't change). Now streaming and abort controllers are keyed
by sessionId, so switching to a different session correctly shows the
idle state while the old session continues streaming in background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:00:29 +08:00
bincxz
29fca33ffd fix: reduce stall timeout to 3s and show status with shimmer effect
- Reduce ACP stall detection from 15s to 3s for faster feedback
- Add statusText field to ChatMessage for transient status display
- Render status text with thinking-shimmer CSS animation
- Clear statusText when real content arrives (onTextDelta)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:35:17 +08:00
bincxz
216ea7f177 feat: show ACP agent status messages (stall detection) in chat
- Add stall detection in ACP stream loop: if no chunk received for 15s,
  send a "Waiting for response..." status event to the chat panel
- Add onStatus callback to AcpAgentCallbacks, render as italic text
- Forward status events from main process to renderer via acp:event IPC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:26:11 +08:00
bincxz
b280caded2 fix: default Codex model selection includes thinking level suffix
When no model is stored, the default was bare "gpt-5.4" which Codex
rejects. Now defaults to "gpt-5.4/xhigh" (highest thinking level)
for models that require a thinking level suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:12:42 +08:00
bincxz
2d4f260f0b fix: address review findings from refactoring review
- Fix stale closure: add updateLastMessage to handleApprovalResponse deps
- Use random heredoc delimiter to prevent content corruption when file
  contains the literal delimiter string
- Remove dead ensureClaudeConfigDir function from claudeHelpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:03:36 +08:00
bincxz
e69bc53aa4 feat: improve error display with structured error messages in AI chat
- Add errorInfo field to ChatMessage with type classification
  (network/auth/timeout/provider/agent/unknown) and retryable flag
- Create errorClassifier.ts to map raw error strings into user-friendly
  structured messages with actionable hints
- Replace inline "**Error:**" text appending with dedicated error messages
  rendered as styled error cards with AlertCircle icon
- Ensure streaming indicator is cleared immediately on error in ACP and
  external agent flows (not just in finally block)
- Mark previous message executionStatus as failed only when it was running

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:55:04 +08:00
bincxz
a55da77471 refactor: fix security issues, remove dead code, and split monolithic files
Security:
- Fix command injection in 15+ shell interpolation sites with shellQuote()
- Add token-based authentication to MCP TCP server
- Fix heredoc delimiter collision in SFTP write fallbacks
- Add case-insensitive flag to mcpServerBridge command blocklist
- Add exhaustive default case to createModelFromConfig

Architecture:
- Split aiBridge.cjs (1971→1483 lines) into 4 modules:
  shellUtils, codexHelpers, claudeHelpers, ptyExec
- Split SettingsAITab.tsx (1480→520 lines) into 10 sub-components
- Extract shared processCattyStream from AIChatSidePanel.tsx (-168 lines)
- Consolidate duplicate PTY execution logic into shared ptyExec.cjs
- Consolidate duplicate stripAnsi/toUnpackedAsarPath utilities

Code quality:
- Remove ~33 debug console.log statements from production code
- Remove dead code: terminal_read_output stub, checkToolPermission,
  isCommandAllowed, READ_ONLY_TOOLS
- Unify bridge type accessor in useAIState.ts (eliminate 15 unsafe casts)
- Add localStorage session pruning (50 sessions / 200 messages cap)
- Fix ModelSelector onBlur race condition (setTimeout → preventDefault)
- Fix duplicate getShellEnv() calls in Codex helpers
- Remove unused _config param from claudeAgentAdapter
- Type claudeAgentAdapter bridge parameter directly as ClaudeBridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:45:50 +08:00
bincxz
33d3a86d83 feat: show permission mode switcher for all agents, not just Catty
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:57:41 +08:00
bincxz
f73c060351 fix: enforce Observer mode for Catty Agent write tools
Catty Agent tools call the bridge directly (not via MCP Server), so the
MCP-level observer enforcement doesn't apply. Add explicit isObserver
guards in all write tool execute functions (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) to return
an error when Observer mode is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:54:19 +08:00
bincxz
304ebf1e3b feat: add inline permission mode switcher for Catty Agent in chat input
Show a clickable chip next to the model selector in ChatInput footer
that lets users quickly toggle between Observer/Confirm/Auto permission
modes. Only visible when Catty Agent is selected (ACP agents handle
their own tool approval flow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:52:19 +08:00
bincxz
2788dbdff5 feat: replace modal permission dialog with inline approval cards and fix approval continuation flow
Replace the full-screen PermissionDialog modal with inline InlineApprovalCard
rendered within chat messages. Implement the multi-turn approval flow so that
clicking Approve actually resumes the agent loop via a new streamText call with
proper SDK tool-approval-response messages.

Fix toolCallId extraction (toolCall.toolCallId, not chunk.toolCallId) and use
correct SDK field name (input, not args) for ToolCallPart content parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:30:55 +08:00
bincxz
84fe0134c9 feat: implement UI-level tool confirmation for Catty Agent confirm mode
Use Vercel AI SDK's native `needsApproval` on write tools (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) when permission
mode is "confirm". When the SDK emits a `tool-approval-request` stream event,
show the existing PermissionDialog component for user to approve/reject.

- tools.ts: replace manual checkToolPermission() calls with `needsApproval`
  property on write tools; keep blocklist checks in execute()
- AIChatSidePanel: handle `tool-approval-request` chunk type, show
  PermissionDialog via Promise-based pause, resolve on user action
- Add i18n key for tool denied message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:04:11 +08:00
bincxz
06dc7400f2 fix: use Sparkles icon for AI settings tab to match top toolbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:10:26 +08:00
bincxz
d1a59ed40c fix: add missing cross-window sync for host permissions and agent model map
The StorageEvent handler was missing cases for STORAGE_KEY_AI_HOST_PERMISSIONS
and STORAGE_KEY_AI_AGENT_MODEL_MAP, so changes made in the settings window
were not picked up by the main window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:08:50 +08:00
bincxz
f90aa81b2c feat: enforce safety settings, blocklist UI, provider UX improvements, and full i18n
Safety enforcement:
- Command Timeout: use user setting in MCP Server (execViaPty/execViaChannel)
  and Catty Agent path (aiBridge execViaPtyForCatty/execViaChannel fallback)
- Max Iterations: read user setting for Catty (stepCountIs) and ACP (via IPC)
- Permission Mode: observer mode hard-blocks write ops in MCP Server dispatch;
  Catty SDK tools wired to checkToolPermission(); all synced via IPC on change

Command Blocklist UI:
- Add editable regex pattern list in Settings AI Safety section
- Reset to defaults button, add/remove patterns
- IPC sync to MCP Server on change and on mount

Provider UX:
- ModelSelector rewritten as combobox: type-to-filter with suggestions dropdown
- All providers use unified ModelSelector (modelsEndpoint optional)
- API key passed as auth header for model fetching (Bearer / x-api-key)
- Skip model fetch when no API key (except Ollama)
- Provider toggle is now mutually exclusive (activating one disables others)
- New providers default to disabled (switch off)
- Custom provider supports editable display name
- No-provider error: friendly message shown in chat

i18n:
- Full i18n coverage for Settings AI tab (~55 keys)
- Full i18n for AI chat panel (placeholder, empty state, time, sessions)
- en.ts and zh-CN.ts locale files updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:05:41 +08:00
bincxz
950819746e fix: per-agent model memory and correct Claude ACP model presets
- Replace single selectedAgentModel state with per-agent agentModelMap
  persisted to localStorage, so each agent remembers its last selected model
- Default to first preset when no prior selection exists
- Fix Claude model presets: use 'default' instead of 'opus' to match
  what claude-code-acp actually exposes (default=Opus 4.6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:52:58 +08:00
bincxz
4a3a4b9d9b fix: restore agent on tab switch, i18n agent settings, icon improvements
- Restore currentAgentId from active session when switching scopes
- Add i18n for "Agent Settings" label in agent selector
- Add agent icons to settings page default agent dropdown
- Replace catty.svg with new icon, use Settings icon for manage button
- Fix lint: missing useCallback deps, no-restricted-globals, useMemo deps
- Guard webContents.send against disposed render frames in windowManager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:25:54 +08:00
bincxz
726ff82a9e fix: improve side panel UI and add Catty agent icon
Enlarge side panel tab buttons for better clickability, persist panel
width to localStorage, switch AI tab icon to MessageSquare, and add
dedicated catty.svg with violet badge styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:40:15 +08:00
bincxz
7e8682d10d fix: preserve ACP provider session when switching chat sessions
Remove chatSessionId from MCP server config env vars so it doesn't
affect the fingerprint calculation. Previously, different chat sessions
in the same workspace produced different fingerprints, causing the ACP
provider to be recreated when switching back to a previous session,
losing all conversation history.

Now the fingerprint only depends on the workspace scope (host session
IDs and MCP port), so providers are correctly reused when returning
to a previous chat session. Each chat session still has its own
provider instance (keyed by chatSessionId in the acpProviders Map).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:35:48 +08:00
bincxz
b2447b06d2 fix: per-scope AI chat isolation and workspace host discovery
- Fix workspace host discovery: use `activeWorkspace.root` instead of
  non-existent `.tree` property, which caused `collectSessionIds` to
  always return empty for workspaces
- Isolate AI chat state per-scope (tab/workspace): activeSessionId,
  isStreaming, abortController, and inputValue are now keyed by
  `${scopeType}:${scopeTargetId}` so different tabs don't interfere
- Add per-chatSession MCP scope metadata to prevent host list mixing
  across workspaces, with chatSessionId passed through the full IPC chain
- Store hostname/username in SSH session objects as fallback for MCP
  host info when renderer metadata is unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:59:47 +08:00
bincxz
ed8a6a6cf2 fix: remove unused Claude Agent SDK path, fix scope for all agent types
- Remove claude-agent-sdk streaming path (unused, causes confusion)
- Claude Agent now uses ACP path like other external agents
- ACP path already has aiMcpUpdateSessions for scope isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 04:17:28 +08:00
bincxz
f0f5803a6d fix: enforce workspace scope isolation for MCP server sessions
The MCP server was exposing ALL terminal sessions to AI agents regardless
of which workspace the agent belonged to. Fixed by:

- Track scoped session IDs when updateSessionMetadata is called
- buildMcpServerConfig now auto-uses current scoped IDs when no explicit
  scope is provided, setting NETCATTY_MCP_SESSION_IDS env var
- handleGetContext falls back to sessionMetadata keys when no explicit
  scopedSessionIds param is passed

This ensures agents only see hosts within their workspace/terminal scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 04:12:21 +08:00
bincxz
f53bc05cb3 feat: overhaul AI settings, fix Catty Agent streaming and tool execution
AI Settings:
- Remove External Agents section, add dedicated Codex/Claude Code sections
  with auto-detection and manual path override
- Add OpenRouter model list auto-fetch with searchable dropdown
- Remove standalone Default Model section, integrate into provider cards
- Provider toggle now acts as mutual-exclusive active selector
- Add cross-window state sync via storage events
- Add ErrorBoundary around AI tab for graceful error handling

Catty Agent fixes:
- Fix streaming: use .chat() instead of default Responses API, use
  getReader() pattern instead of for-await, handle text-delta/text chunk
  types correctly per SDK v6
- Fix API key: decrypt encrypted key before passing to SDK
- Fix terminal_execute: use PTY stream (visible in terminal) with MCP
  markers instead of invisible SSH exec channel
- Fix multi-turn: only pass user/assistant text history, skip tool
  messages to avoid SDK schema validation errors
- Fix tool result display: create new assistant message after tool
  results so follow-up text renders correctly

Other:
- Add netcatty:ai:resolve-cli IPC handler for CLI path validation
- Remove Gemini from agent discovery (only Codex + Claude Code)
- Fix lint errors across ChatInput, TerminalLayer, SettingsAITab
- Strip MCP markers from tool execution stdout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 03:53:39 +08:00
bincxz
3136100514 feat: image attachments, model selector with thinking levels, PTY marker filtering, and UX improvements
- Add image paste/drop support with base64 encoding and chat display
- Add + button popover (Files, Image, Mention Host) with @ auto-complete
- Add model selector with Codex thinking level sub-menus (GPT 5.4, Codex 5.x, o3/o4-mini)
- Switch Claude Code to ACP protocol; remove CLAUDE_CONFIG_DIR isolation
- Filter PTY exec markers in preload data pipe (precise regex, preserves command echo)
- Increase PTY exec timeout to 5min with Ctrl+C cancellation on abort
- Fix tool call loading spinner (only animate during active streaming)
- Reset active session on terminal/workspace switch
- Add AI sparkle button in top bar to toggle AI panel
- Display user-attached images in chat message list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 02:03:11 +08:00
bincxz
847df7a023 feat: add MCP server for remote host access, model selector, and PTY execution
- Create netcatty-remote-hosts MCP server (TCP bridge + stdio child process)
  exposing terminal_execute, sftp_*, get_environment tools to ACP agents
- Execute commands via PTY stream with self-erasing markers for terminal
  visibility; disable pagers automatically; 60s timeout fallback
- Inject MCP server into both Codex (ACP) and Claude Code (ACP) sessions
- Add model selector popover with hardcoded presets for Claude (Opus/Sonnet/
  Haiku) and Codex (GPT 5.4, Codex 5.3/5.2/5.1, o3/o4-mini) with thinking
  level sub-menus
- Fix multi-step tool call message flow (mutable flag instead of stale closure)
- Remove CLAUDE_CONFIG_DIR isolation to fix Claude auth; switch Claude to ACP
- Add startup cleanup for orphaned AI sessions
- Guard collectSessionIds against undefined workspace tree
- Remove permission mode chip from chat input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 00:43:04 +08:00
bincxz
150724fc7c fix: enforce session-agent binding and scope-based lifecycle management
- Switching agent deactivates current session, next message creates a new one
- Filter history sessions by both scopeType and targetId
- Restore agent selector when resuming a historical session
- Auto-cleanup AI sessions when terminal/workspace instances are destroyed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:09 +08:00
bincxz
8949394756 feat: add Claude Agent SDK streaming and Codex OAuth integration
Integrate Claude Agent SDK for direct streaming chat, add Codex login/logout
flow with OAuth support in settings, improve AI chat panel UI and agent
discovery, and update build config for new dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:27:41 +08:00
bincxz
7f3214e088 feat: integrate external AI agents via ACP protocol
- Add auto-discovery of CLI agents (Claude Code, Codex, Gemini) from system PATH
- Integrate ACP (Agent Client Protocol) for real-time streaming with codex-acp
- Bundle @zed-industries/codex-acp binary for reliable agent spawning
- Add ThinkingBlock component with shimmer animation and auto-collapse
- Refactor chat UI: no avatars, bordered user bubbles, plain assistant text
- Support {prompt} placeholder in agent args for flexible invocation
- Add persistent ACP sessions with proper cleanup on app exit
- Detect auth errors and show actionable messages to users
- Fallback to raw process spawn for agents without ACP support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:21:28 +08:00
陈大猫
eaab7d72cb Merge pull request #341 from yuzifu/fix-theme-and-fontsize-to-new-host 2026-03-14 16:22:46 +08:00
yuzifu
63a7c06037 Allow get theme/fontsize from system config for new host 2026-03-14 15:57:49 +08:00
bincxz
72887c35b5 feat: add AI chat panel with Vercel AI SDK integration
Add a comprehensive AI assistant feature to netcatty, enabling AI-powered
terminal automation and multi-host orchestration.

Core features:
- AI chat side panel (Zed-style) with agent selector, session history,
  conversation export (Markdown/JSON/TXT), and streaming responses
- Catty Agent: built-in terminal assistant with 9 tools (terminal exec,
  SFTP read/write, multi-host orchestration) using zod schemas
- BYOK provider support: OpenAI, Anthropic, Google, Ollama, OpenRouter,
  and custom OpenAI-compatible endpoints
- Three-tier permission model: Observer / Confirm / Autonomous
- Command safety: blocklist patterns, doom loop detection, abort support
- External agent support: ACP protocol (JSON-RPC over stdio) for
  Claude Agent, Codex CLI, Gemini CLI, and custom agents

Tech stack:
- Vercel AI SDK (ai, @ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google)
  with streamText + fullStream for streaming tool-call loops
- AI Elements: adapted conversation (use-stick-to-bottom), message
  (streamdown markdown), prompt-input (InputGroup) components
- Custom bridge fetch adapter routing all API calls through Electron
  main process IPC to avoid CORS
- zod for tool parameter schemas

UI components:
- AIChatSidePanel, AgentSelector, ChatInput, ChatMessageList
- PermissionDialog, ExecutionPlan, ConversationExport
- AI Elements: conversation, message, tool-call, prompt-input
- New UI primitives: InputGroup, Spinner
- Settings AI tab for BYOK configuration and safety settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:22:28 +08:00
陈大猫
4373a8ce14 Merge pull request #339 from binaricat/feat/toolbar-tooltips
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
ui: add styled tooltips to terminal and SFTP toolbar buttons
2026-03-14 01:51:15 +08:00
bincxz
007fe47310 ui: add styled tooltips to terminal and SFTP toolbar buttons
Replace native title attributes with Radix UI Tooltip components for
a consistent, styled tooltip experience across both toolbars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:47:46 +08:00
陈大猫
9109fc2f6e Merge pull request #338 from binaricat/feat/default-dark-theme
feat: default theme to dark for new users
2026-03-14 01:42:30 +08:00
bincxz
961f79d3d8 feat: change default theme from system to dark for new users
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:41:54 +08:00
陈大猫
494fc27454 Merge pull request #337 from binaricat/fix/memory-leaks-and-cpu-optimization
fix: resolve memory leaks and reduce unnecessary CPU consumption
2026-03-14 01:38:36 +08:00
bincxz
a85324c9fb fix: resolve memory leaks and reduce unnecessary CPU consumption
- Fix onSelectionChange listener leak in Terminal.tsx (missing dispose on cleanup)
- Debounce window resize handler in TopTabs.tsx to prevent IPC storm
- Use .once() for SSH/SFTP/PortForward connection lifecycle events (ready/error/timeout/close)
  to prevent listener accumulation across sessions
- Clean up sessionEncodings/sessionDecoders maps in all error paths in sshBridge
- Use .once() for execCommand() connection events (creates new conn per call)
- Remove duplicate requestAnimationFrame in useSftpPaneVirtualList
- Capture and dispose OSC 7 parser handler in createXTermRuntime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:36:12 +08:00
陈大猫
860739bb97 Merge pull request #336 from binaricat/feat/side-panel-position-toggle
feat: add toggle to move side panel between left and right
2026-03-14 01:14:39 +08:00
bincxz
a6494bfb78 feat: add toggle button to move side panel between left and right
Add a position toggle button next to the close button in the side panel
header. The position preference is persisted in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:11:16 +08:00
bincxz
1fa11c2c2d ui: show spinning icon during terminal connection progress
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:51:47 +08:00
陈大猫
35b8990a9c Merge pull request #335 from binaricat/fix/terminal-block-char-gaps
fix: enable customGlyphs to eliminate gaps between block characters
2026-03-14 00:39:47 +08:00
bincxz
67536c9424 fix: enable customGlyphs to eliminate gaps between block characters
Enable xterm.js customGlyphs option so box-drawing and block characters
are rendered by canvas instead of font glyphs, eliminating visible gaps.

Closes #331

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:39:29 +08:00
陈大猫
4dbbb96e4d Merge pull request #334 from binaricat/fix/webdav-self-signed-cert
feat: allow ignoring certificate errors for WebDAV connections
2026-03-14 00:35:19 +08:00
bincxz
5cb8b348b3 fix: handle allowInsecure in WebDAVAdapter fallback path
- Add httpsAgent with rejectUnauthorized:false in WebDAVAdapter.createClient()
  so the fallback (non-bridge) path also respects the allowInsecure option
- Use explicit ternary for allowInsecure config serialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:32:54 +08:00
bincxz
06efcfe384 feat: add option to ignore certificate errors for WebDAV connections
Allow users to bypass TLS certificate verification for WebDAV endpoints
using self-signed certificates, which is common for LAN NAS devices
(Synology, FNAS, Unraid, etc.).

Closes #332

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:28:23 +08:00
陈大猫
4877c934fa feat: move Scripts and Theme to side panel sub-tabs (#333)
* feat: move Scripts and Theme from toolbar popups to side panel sub-tabs

Migrate Scripts (snippet library) and Theme customization from toolbar
popover/modal dialogs into the left side panel alongside SFTP. The panel
header now shows three tab buttons (SFTP / Scripts / Theme) so users can
switch between sub-panels without losing SFTP connections.

- Add ScriptsSidePanel with package hierarchy, breadcrumb nav and search
- Add ThemeSidePanel adapted from ThemeCustomizeModal (no preview pane)
- Generalize TerminalLayer state from sftpOpenTabs to sidePanelOpenTabs
- Simplify TerminalToolbar by removing inline popover and modal rendering
- Clicking the already-active tab button is a no-op; only X closes panel
- Theme/font changes apply in real-time to the actual terminal behind

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

* fix: address PR review findings for side panel migration

- Clean up sftpInitialLocationForTab on panel close
- Remove unused handleCloseSidePanel from deps array
- Re-focus terminal after snippet execution from side panel
- Use props directly in ThemeSidePanel instead of mirrored local state
- Use ?? instead of || for falsy-safe theme/font/size defaults
- Extract isFocusedHostLocal into memoized value

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:21:34 +08:00
陈大猫
c542520dee feat: SFTP sidebar polish - workspace caching, toolbar overflow, terminal cwd navigation
## Summary
- Add SFTP side panel with workspace-level connection caching for instant switching between terminal endpoints
- Responsive toolbar with overflow menu that collapses action buttons when panel is narrow, prioritizing breadcrumb path display
- Silent terminal CWD detection via separate SSH exec channel (no visible commands in terminal)
- Extract SftpTransferQueue as reusable component with i18n support
- Remove passphrase from port forwarding credentials (decrypted at load time)
- Add compressed upload support to uploadEntriesDirect
- Fix various eslint warnings and code quality issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:39:56 +08:00
陈大猫
0b61d10953 Merge pull request #329 from MiracleLau/fix-portforward-no-passphrase-given
fix: no passphrase given error on port forwarding launch
2026-03-13 18:45:02 +08:00
MiracleLau
347361bc7b fix: complete incomplete parameters for startTunnel 2026-03-13 17:02:15 +08:00
MiracleLau
746c336ee1 fix: no passphrase given error on port forwarding launch 2026-03-13 16:43:22 +08:00
陈大猫
6373762399 Merge pull request #327 from binaricat/feat/tab-redesign-os-icons
feat: redesign tab bar with OS/distro icons
2026-03-13 15:08:47 +08:00
陈大猫
27b8d4a410 Merge pull request #328 from yuzifu/fix-host-group
fix: show all nodes in the Group field of host details.
2026-03-13 15:07:06 +08:00
yuzifu
27773c58db fix: show all nodes in the Group field of host details. 2026-03-13 14:59:06 +08:00
bincxz
ecb48e89a5 feat: redesign tab bar to Windows Terminal style with OS/distro icons
- Redesign tabs from rounded rectangle + accent border to flat-bottom
  Windows Terminal style with top accent line indicator
- Show OS/distro icons with brand background colors in session tabs
- Add OS-specific icons (macOS/Windows/Linux) for local terminal tabs
  with auto-detection via navigator.userAgent
- Add SVG assets for macOS, Windows, and Linux logos
- Give Vaults tab a distinct style (rounded, semi-transparent bg,
  no accent line) to differentiate from session tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:54:00 +08:00
陈大猫
d609d8edb3 Merge pull request #326 from binaricat/fix/sftp-modal-upload-race-condition
fix: prevent SFTP modal drag-upload from targeting stale directory
2026-03-13 14:13:37 +08:00
bincxz
5f91fbbab8 fix: prevent SFTP modal drag-upload from targeting stale directory
When reopening the SFTP modal via drag-and-drop, the session effect's
initialization IIFE runs async (ensureSftp + listSftp ~0.5s). During
this window, dependency changes (e.g. loadFiles recreation from
files.length change by the layout effect clearing stale cache) can
re-trigger the session effect. Since initializedRef is already true,
the effect falls through to loadFiles(currentPath) with the OLD path.
If this loadFiles resolves before the IIFE, loading transitions to
false prematurely, causing the auto-upload to snapshot the stale
currentPathRef and upload to the wrong directory.

Add an initializingRef flag that is set when the initialization IIFE
starts and cleared in its finally block. The fallthrough loadFiles
call is skipped while initializingRef is true, ensuring only the
IIFE's completion triggers the loading transition that the auto-upload
effect relies on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:06:26 +08:00
陈大猫
89c3c7f83a Merge pull request #325 from binaricat/fix/mac-tray-update-restart
fix: destroy system tray before quitAndInstall on macOS
2026-03-13 13:36:52 +08:00
bincxz
ee391bcc32 fix: destroy system tray before quitAndInstall on macOS
On macOS, the system tray keeps the app process alive after all windows
are closed, preventing quitAndInstall from completing the restart.
Clean up the tray and its panel window before calling quitAndInstall so
the app can exit cleanly and the installer can proceed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:32:44 +08:00
陈大猫
26fd5023f5 Merge pull request #324 from binaricat/fix-sync-knowhosts
fix: known hosts sync not work
2026-03-13 13:27:09 +08:00
bincxz
49543abcff Merge main into fix-sync-knowhosts and resolve conflicts
Resolve conflict in useAutoSync.ts by integrating getEffectiveKnownHosts
into the refactored getSyncSnapshot function, avoiding duplication in
getDataHash which now delegates to getSyncSnapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:02:35 +08:00
陈大猫
6bab971de8 Merge pull request #323 from binaricat/codex/fix-auto-sync-overlap
Fix overlapping auto-sync retries
2026-03-13 11:44:26 +08:00
bincxz
392a57f95b Fix overlapping auto-sync handling 2026-03-13 11:38:17 +08:00
yuzifu
85e3e8b26f fix: known hosts sync not work 2026-03-13 11:30:29 +08:00
陈大猫
9747498833 Merge pull request #321 from yuzifu/fix-hosts-count
fix: show hosts count in the group
2026-03-13 11:02:59 +08:00
yuzifu
520e2c3f9d fix: show hosts count in the group 2026-03-13 10:47:58 +08:00
193 changed files with 24874 additions and 1486 deletions

View File

@@ -84,13 +84,14 @@ jobs:
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — builds directly on ubuntu-latest (no container).
# v1.0.39 used a debian:bullseye container which broke native module
# packaging (node-pty .node file missing from asar.unpacked). Reverted
# to the v1.0.38 approach. See #264.
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}

7
.gitignore vendored
View File

@@ -35,8 +35,11 @@ coverage
*.sln
*.sw?
# Claude Code local settings
/.claude/settings.local.json
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)

54
App.tsx
View File

@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -180,6 +181,13 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
@@ -286,10 +294,12 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
});
@@ -314,6 +324,8 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -370,7 +382,7 @@ function App({ settings }: { settings: SettingsState }) {
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
[keys]
);
@@ -439,7 +451,7 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
@@ -1056,31 +1068,12 @@ function App({ settings }: { settings: SettingsState }) {
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
// Auto-save is now handled by real-time streaming in the main process
// via sessionLogStreamManager. No renderer-side fallback needed.
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
}, [sessions, connectionLogs, updateConnectionLog]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -1201,6 +1194,7 @@ function App({ settings }: { settings: SettingsState }) {
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
@@ -1274,6 +1268,7 @@ function App({ settings }: { settings: SettingsState }) {
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
@@ -1306,6 +1301,17 @@ function App({ settings }: { settings: SettingsState }) {
onSplitSession={splitSession}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -59,6 +59,8 @@
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -110,37 +112,37 @@
<a name="デモ"></a>
# デモ
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
動画で機能をさっと確認できます(素材は `screenshots/gifs/`
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくださったすべての方に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="ライセンス"></a>
# ライセンス
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="star-履歴"></a>
# Star 履歴
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -59,6 +59,8 @@
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Star History](#star-history)
- [License](#license)
---
@@ -111,37 +113,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
<a name="demos"></a>
# Demos
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +215,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="getting-started"></a>
@@ -309,7 +328,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
Thanks to all the people who contribute!
See: https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +341,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
---
<a name="star-history"></a>
# Star History
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -59,6 +59,8 @@
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -111,37 +113,37 @@
<a name="演示"></a>
# 演示
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="快速开始"></a>
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
感谢所有参与贡献的人!
查看:https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="star-历史"></a>
# Star 历史
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -34,6 +34,7 @@ const en: Messages = {
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.az': 'A-z',
@@ -49,6 +50,7 @@ const en: Messages = {
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -113,6 +115,8 @@ const en: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -140,6 +144,8 @@ const en: Messages = {
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
@@ -264,6 +270,17 @@ const en: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'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.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
@@ -595,7 +612,11 @@ const en: Messages = {
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
@@ -724,6 +745,13 @@ const en: Messages = {
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
@@ -829,8 +857,8 @@ const en: Messages = {
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
@@ -1121,6 +1149,7 @@ const en: Messages = {
'cloudSync.webdav.password': 'Password',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Show secret',
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
'cloudSync.webdav.validation.token': 'Token is required.',
@@ -1396,6 +1425,7 @@ const en: Messages = {
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
@@ -1472,6 +1502,164 @@ const en: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'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',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
'ai.chat.recommendAllow': 'Allow',
'ai.chat.recommendConfirm': 'Confirm',
'ai.chat.recommendDeny': 'Deny',
'ai.chat.exportConversation': 'Export conversation',
'ai.chat.exportAs': 'Export As',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Plain Text',
'ai.chat.thinking': 'Thinking',
'ai.chat.thoughtFor': 'Thought for {duration}',
'ai.chat.thought': 'Thought',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': 'Detected on this machine',
'ai.chat.rescan': 'Re-scan',
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before actions',
'ai.chat.permAutoDesc': 'Execute 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.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
};
export default en;

View File

@@ -22,6 +22,7 @@ const zhCN: Messages = {
'common.use': '使用',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.az': 'A-z',
'sort.za': 'Z-a',
@@ -36,6 +37,7 @@ const zhCN: Messages = {
// Dialogs / prompts
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
@@ -97,6 +99,8 @@ const zhCN: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -124,6 +128,8 @@ const zhCN: Messages = {
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
@@ -433,7 +439,11 @@ const zhCN: Messages = {
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
@@ -539,8 +549,8 @@ const zhCN: Messages = {
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
@@ -802,6 +812,7 @@ const zhCN: Messages = {
'cloudSync.webdav.password': '密码',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '显示密钥',
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
'cloudSync.webdav.validation.token': '请输入 Token。',
@@ -1049,6 +1060,13 @@ const zhCN: Messages = {
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
// Settings > SFTP 自动打开侧栏
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
@@ -1135,6 +1153,17 @@ const zhCN: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
'terminal.osc52.readPrompt.allow': '允许',
'terminal.osc52.readPrompt.deny': '拒绝',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
@@ -1411,6 +1440,7 @@ const zhCN: Messages = {
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
@@ -1487,6 +1517,164 @@ const zhCN: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
'ai.providers.apiKeyConfigured': 'API Key 已配置',
'ai.providers.noApiKey': '未设置 API Key',
'ai.providers.configure': '配置',
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key将作为 CODEX_API_KEY 传递)。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '检查',
'ai.codex.openLogin': '打开登录',
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty内置',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
'ai.chat.permissionRequired': '需要权限',
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
'ai.chat.recommendAllow': '允许',
'ai.chat.recommendConfirm': '确认',
'ai.chat.recommendDeny': '拒绝',
'ai.chat.exportConversation': '导出对话',
'ai.chat.exportAs': '导出为',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '纯文本',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本机检测到',
'ai.chat.rescan': '重新扫描',
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自主',
'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.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Web Search
'ai.webSearch.title': '网络搜索',
'ai.webSearch.enable': '启用网络搜索',
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
'ai.webSearch.provider': '搜索供应商',
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
'ai.webSearch.apiKey': 'API 密钥',
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL必填。',
'ai.webSearch.maxResults': '最大结果数',
'ai.webSearch.maxResults.description': '搜索返回的最大结果数1-20。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
};
export default zhCN;

View File

@@ -30,7 +30,8 @@ class CustomThemeStore {
this.setupCrossWindowSync();
}
private loadFromStorage = () => {
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
@@ -39,7 +40,7 @@ class CustomThemeStore {
} catch {
// ignore corrupt data
}
this.cachedAllThemes = null; // invalidate cache
this.notify();
};
private saveToStorage = () => {

View File

@@ -0,0 +1,52 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
updatedAt: number;
}
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
/**
* Build a cache key that includes connection details so that the same host ID
* with different session-time overrides (port, protocol) uses separate entries.
*/
export const buildCacheKey = (
hostId: string,
hostname?: string,
port?: number,
protocol?: string,
sftpSudo?: boolean,
username?: string,
): string => {
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
};
export const getSharedRemoteHostCache = (
cacheKey: string,
): SharedRemoteHostCacheEntry | null => {
const entry = sharedRemoteHostCache.get(cacheKey);
if (!entry) return null;
if (Date.now() - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
sharedRemoteHostCache.delete(cacheKey);
return null;
}
return entry;
};
export const setSharedRemoteHostCache = (
cacheKey: string,
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
): void => {
sharedRemoteHostCache.set(cacheKey, {
...entry,
updatedAt: Date.now(),
});
};

View File

@@ -59,4 +59,5 @@ export interface SftpStateOptions {
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
}

View File

@@ -5,6 +5,7 @@ import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncodin
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpConnectionsParams {
hosts: Host[];
@@ -24,14 +25,16 @@ interface UseSftpConnectionsParams {
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -55,22 +58,24 @@ export const useSftpConnections = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) {
if (!sideTabs.activeTabId || options?.forceNewTab) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
@@ -89,6 +94,14 @@ export const useSftpConnections = ({
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
connectionCacheKeyMapRef.current.set(
connectionId,
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
);
}
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
@@ -96,18 +109,22 @@ export const useSftpConnections = ({
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
// When forceNewTab is set, we're preserving the old tab for instant switching —
// don't close its SFTP session or clear its cache.
if (!options?.forceNewTab) {
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
@@ -162,22 +179,33 @@ export const useSftpConnections = ({
}));
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
currentPath: cachedStartPath,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
// Always show loading while connecting — even with cached files.
// The cached file list is shown as a preview, but the pane stays
// non-interactive until the SFTP session is actually established.
loading: true,
reconnecting: prev.reconnecting,
error: null,
files: prev.reconnecting ? prev.files : [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
@@ -238,72 +266,137 @@ export const useSftpConnections = ({
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
break;
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
if (startPath === "/") {
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
}
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
if (sharedHostCache && provisionalCacheKey) {
dirCacheRef.current.set(provisionalCacheKey, {
files: sharedHostCache.files,
timestamp: Date.now(),
});
}
let files: SftpFileEntry[] = [];
try {
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
} catch {
// Cached path may be stale (deleted, permissions changed).
// Remove the provisional cache entry so phantom files don't resurface.
if (provisionalCacheKey) {
dirCacheRef.current.delete(provisionalCacheKey);
}
// Fall back to homeDir, then "/", chaining attempts.
let fallbackSucceeded = false;
if (sharedHostCache && startPath !== homeDir) {
try {
startPath = homeDir;
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// homeDir also failed, try root
}
}
if (!fallbackSucceeded && startPath !== "/") {
try {
startPath = "/";
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// root also failed
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
setSharedRemoteHostCache(hostCacheKey, {
path: startPath,
homeDir,
files,
filenameEncoding,
});
reconnectingRef.current[side] = false;
@@ -314,7 +407,7 @@ export const useSftpConnections = ({
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
homeDir,
}
: null,
files,
@@ -356,13 +449,17 @@ export const useSftpConnections = ({
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
if (
autoConnectLocalOnMount &&
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftTabs.tabs.length]);
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {

View File

@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
// Re-export UploadResult for external usage
export type { UploadResult };
@@ -20,6 +22,8 @@ interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
@@ -38,10 +42,16 @@ interface SftpExternalOperationsResult {
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
@@ -53,6 +63,8 @@ export const useSftpExternalOperations = (
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
@@ -63,6 +75,10 @@ export const useSftpExternalOperations = (
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
@@ -324,6 +340,7 @@ export const useSftpExternalOperations = (
pane.filenameEncoding,
);
watchId = result.watchId;
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
@@ -337,7 +354,9 @@ export const useSftpExternalOperations = (
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
@@ -349,6 +368,8 @@ export const useSftpExternalOperations = (
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
@@ -374,6 +395,8 @@ export const useSftpExternalOperations = (
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
@@ -505,7 +528,12 @@ export const useSftpExternalOperations = (
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromDataTransfer(
@@ -532,6 +560,7 @@ export const useSftpExternalOperations = (
}
},
[
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
@@ -541,6 +570,90 @@ export const useSftpExternalOperations = (
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string },
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const directUploadBridge: UploadBridge = {
...createUploadBridge,
};
try {
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: directUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller,
);
// Refresh the current directory and invalidate the upload target's
// cache entry. If the user navigated away during the upload, the
// invalidation ensures returning to the target path triggers a fresh
// listing instead of serving stale cached data.
const livePane = getActivePane(side);
if (livePane?.connection) {
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
await refresh(side);
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
if (controller) {
@@ -566,7 +679,9 @@ export const useSftpExternalOperations = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
};
};

View File

@@ -4,8 +4,10 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
@@ -15,6 +17,7 @@ interface UseSftpPaneActionsParams {
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
@@ -50,6 +53,7 @@ interface UseSftpPaneActionsResult {
}
export const useSftpPaneActions = ({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -59,6 +63,7 @@ export const useSftpPaneActions = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -68,6 +73,31 @@ export const useSftpPaneActions = ({
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
// Prefer the per-connection cache key — it's set at connect time and
// correctly identifies the endpoint even when multiple tabs share the
// same hostId with different session-time overrides.
if (connectionId) {
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
if (perConnKey) return perConnKey;
}
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
const connHost = lastConnectedHostRef.current[side];
if (connHost && connHost !== "local" && connHost.id === hostId) {
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
}
// Fall back to vault host
const host = hostsRef.current.find(h => h.id === hostId);
if (host) {
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
}
return hostId;
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
@@ -134,6 +164,19 @@ export const useSftpPaneActions = ({
error: null,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
// shared cache is a best-effort optimization and hostId uniquely
// identifies the connection in the common case. Session-time
// overrides create separate connections with distinct cache keys
// at the connect() layer.
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files: cached.files,
filenameEncoding: pane.filenameEncoding,
});
}
return;
}
@@ -258,6 +301,14 @@ export const useSftpPaneActions = ({
loading: false,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files,
filenameEncoding: pane.filenameEncoding,
});
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
@@ -295,6 +346,7 @@ export const useSftpPaneActions = ({
},
[
getActivePane,
getActivePaneCacheKey,
updateTab,
leftTabsRef,
rightTabsRef,

View File

@@ -0,0 +1,611 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AISession,
AIPermissionMode,
ProviderConfig,
HostAIPermission,
ExternalAgentConfig,
ChatMessage,
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiAcpCleanup(sessionId).catch(() => {});
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
);
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
);
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
);
// ── Permission Model ──
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
});
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
);
// ── External Agents ──
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
);
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
// ── Safety Settings ──
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
setAgentModelMapRaw(prev => {
const next = { ...prev, [agentId]: modelId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
}, []);
const setActiveProviderId = useCallback((id: string) => {
setActiveProviderIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
}, []);
const setActiveModelId = useCallback((id: string) => {
setActiveModelIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
}, []);
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
setGlobalPermissionModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
// Sync to MCP Server bridge (observer mode blocks write operations)
const bridge = getAIBridge();
bridge?.aiMcpSetPermissionMode?.(mode);
}, []);
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
setHostPermissionsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
return next;
});
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
return next;
});
}, []);
const setDefaultAgentId = useCallback((id: string) => {
setDefaultAgentIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
}, []);
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
// Sync to MCP Server bridge so ACP agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
// Sync to MCP Server bridge
const bridge = getAIBridge();
bridge?.aiMcpSetCommandTimeout?.(value);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
// Sync to MCP Server bridge (used by ACP agent path)
const bridge = getAIBridge();
bridge?.aiMcpSetMaxIterations?.(value);
}, []);
// ── Cross-window sync via storage events ──
// When the settings window updates localStorage, the main window picks up changes.
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
switch (e.key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) {
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
break;
}
setProvidersRaw(parsed ?? []);
break;
}
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
break;
case STORAGE_KEY_AI_ACTIVE_MODEL:
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
break;
case STORAGE_KEY_AI_PERMISSION_MODE: {
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
setGlobalPermissionModeRaw(mode);
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
}
break;
}
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) {
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
break;
}
setExternalAgentsRaw(agents ?? []);
break;
}
case STORAGE_KEY_AI_DEFAULT_AGENT:
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
break;
}
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
if (!Number.isFinite(timeout)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
break;
}
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) {
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
break;
}
setMaxIterationsRaw(iters);
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (perms != null && !Array.isArray(perms)) {
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
break;
}
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
}, []);
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => {
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const debouncedPersistSessions = useCallback(() => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return; // Skip writes after unmount
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
persistTimerRef.current = null;
}, 500);
}, []);
// Flush pending debounced writes on unmount
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
persistSessions(sessionsRef.current);
}
};
}, [persistSessions]);
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
const now = Date.now();
const session: AISession = {
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
title: 'New Chat',
agentId: agentId || defaultAgentId,
scope,
messages: [],
createdAt: now,
updatedAt: now,
};
setSessionsRaw(prev => {
const next = [session, ...prev];
persistSessions(next);
return next;
});
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, session.id);
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupAcpSessions([sessionId]);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
return prev;
});
}
}, [persistSessions]);
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
return prev;
});
}, [persistSessions]);
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
persistSessions(next);
return next;
});
}, [persistSessions]);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => {
const next = prev.map(s => (
s.id === sessionId
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId || s.messages.length === 0) return s;
const msgs = [...s.messages];
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const idx = s.messages.findIndex(m => m.id === messageId);
if (idx === -1) return s;
const msgs = [...s.messages];
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const clearSessionMessages = useCallback((sessionId: string) => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
if (!s.scope.targetId) return true;
// Keep sessions whose target still exists
return activeTargetIds.has(s.scope.targetId);
});
if (next.length !== prev.length) {
persistSessions(next);
}
return next;
});
}, [persistSessions]);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders(prev => [...prev, provider]);
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
setProviders(prev => prev.filter(p => p.id !== id));
// Use the raw setter to avoid stale closure over setActiveProviderId
setActiveProviderIdRaw(prevId => {
if (prevId === id) {
const next = '';
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
return next;
}
return prevId;
});
}, [setProviders]);
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
return {
// Provider config
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
hostPermissions,
setHostPermissions,
// External agents
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
// Safety
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
// Per-agent model memory
agentModelMap,
setAgentModel,
// Web search
webSearchConfig,
setWebSearchConfig,
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
setActiveSessionId,
createSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
};
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
) {
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discover = useCallback(async () => {
const bridge = getBridge();
if (!bridge) return;
setIsDiscovering(true);
try {
const agents = await bridge.aiDiscoverAgents();
setDiscoveredAgents(agents);
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
}
}, []);
// Discover on mount
useEffect(() => {
discover();
}, [discover]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
let changed = false;
const next = prev.map((ea) => {
// Only update agents that were auto-discovered (id starts with "discovered_")
if (!ea.id.startsWith('discovered_')) return ea;
const match = discoveredAgents.find(
(da) => ea.command === da.path || ea.command === da.command,
);
if (!match) return ea;
// Check if args or ACP config differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
}
return ea;
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(
(da) => !externalAgents.some(
(ea) => ea.command === da.command || ea.command === da.path,
),
);
// Build ExternalAgentConfig from a discovered agent
const enableAgent = useCallback(
(agent: DiscoveredAgent): ExternalAgentConfig => {
return {
id: `discovered_${agent.command}`,
name: agent.name,
command: agent.path || agent.command,
args: agent.args,
icon: agent.icon,
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
};
},
[],
);
return {
discoveredAgents,
unconfiguredAgents,
isDiscovering,
rediscover: discover,
enableAgent,
};
}

View File

@@ -16,8 +16,10 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
@@ -30,7 +32,9 @@ interface AutoSyncConfig {
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
}
@@ -48,16 +52,15 @@ interface SyncNowOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const isInitializedRef = useRef(false);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
// If port-forwarding hook state is still [] (async init in progress),
// fall back to localStorage to avoid uploading an empty array that
// overwrites the cloud snapshot.
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
@@ -72,6 +75,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
@@ -80,45 +86,38 @@ export const useAutoSync = (config: AutoSyncConfig) => {
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
knownHosts: effectiveKnownHosts,
};
}, [
config.hosts,
config.keys,
config.identities,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
}, [getSyncSnapshot]);
// Create a hash of current data for comparison
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
// Same fallback as buildPayload
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
@@ -130,6 +129,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.noProvider'));
}
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
@@ -151,6 +154,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
@@ -160,6 +164,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
onApplyPayload(result.mergedPayload);
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
}
for (const result of results.values()) {
if (!result.success) {
if (result.conflictDetected) {
@@ -169,7 +183,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
lastSyncedDataRef.current = getDataHash();
lastSyncedDataRef.current = dataHash;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -179,8 +193,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, t]);
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
@@ -203,18 +219,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
try {
console.log('[AutoSync] Checking remote version...');
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
console.log('[AutoSync] Remote is newer, applying...');
config.onApplyPayload(remotePayload);
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Don't show error toast for initial check - it's not critical
}
}, [sync, config, t]);
}, [sync, config, buildPayload, t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -231,11 +255,25 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -253,7 +291,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -0,0 +1,79 @@
/**
* useFileUpload - Handle file paste/drop with base64 conversion
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
import { getPathForFile } from '../../lib/sftpFileUtils';
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string; // data:...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
filePath?: string; // original filesystem path (Electron only)
}
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useFileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
const addFiles = useCallback(async (inputFiles: File[]) => {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return;
const newFiles: UploadedFile[] = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
}
const filePath = getPathForFile(file);
return { id, filename, dataUrl, base64Data, mediaType, filePath };
}),
);
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const clearFiles = useCallback(() => {
setFiles([]);
}, []);
return { files, addFiles, removeFile, clearFiles };
}

View File

@@ -17,7 +17,7 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string }[];
keys: { id: string; privateKey: string; passphrase: string }[];
}
/**
@@ -30,7 +30,7 @@ export const usePortForwardingAutoStart = ({
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
// Keep refs in sync
useEffect(() => {

View File

@@ -63,7 +63,7 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -377,7 +377,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,

View File

@@ -569,6 +569,7 @@ export const useSessionState = () => {
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);

View File

@@ -21,16 +21,19 @@ import {
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../state/customThemeStore';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
@@ -39,7 +42,7 @@ import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>
@@ -61,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -229,6 +233,10 @@ export const useSettingsState = () => {
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -264,7 +272,17 @@ export const useSettingsState = () => {
if (stored === null) return true;
return stored === 'true';
});
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
@@ -332,6 +350,62 @@ export const useSettingsState = () => {
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
}, []);
const rehydrateAllFromStorage = useCallback(() => {
// Theme & appearance (already have helper)
syncAppearanceFromStorage();
syncCustomCssFromStorage();
// UI Font
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
if (storedFont) setUiFontFamilyId(storedFont);
// Language
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
if (storedTermSettings) {
try {
const parsed = JSON.parse(storedTermSettings);
setTerminalSettings(parsed);
} catch { /* ignore */ }
}
// Keyboard
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (storedKb) {
try {
setCustomKeyBindings(JSON.parse(storedKb));
} catch { /* ignore */ }
}
// Editor
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
@@ -457,6 +531,15 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -622,11 +705,32 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -711,6 +815,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -734,7 +844,7 @@ export const useSettingsState = () => {
// Register/unregister the global hotkey in main process
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
@@ -755,7 +865,13 @@ export const useSettingsState = () => {
});
}
}
}, [toggleWindowHotkey, notifySettingsChanged]);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
@@ -770,6 +886,41 @@ export const useSettingsState = () => {
}
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, []);
// Persist auto-update enabled setting.
// Skip IPC on initial mount to avoid overwriting the main-process preference
// file when localStorage has been cleared (where the default is true).
const autoUpdateMountedRef = useRef(false);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
if (!autoUpdateMountedRef.current) {
autoUpdateMountedRef.current = true;
return; // Skip IPC on initial mount
}
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -892,6 +1043,8 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -912,6 +1065,21 @@ export const useSettingsState = () => {
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
]),
};
};

View File

@@ -101,12 +101,26 @@ export const useSftpState = (
}
}, []);
const clearDirCacheEntry = useCallback((connectionId: string, path: string) => {
// Remove all encoding variants of this path from the cache
for (const key of dirCacheRef.current.keys()) {
if (key.startsWith(`${connectionId}::`) && key.endsWith(`::${path}`)) {
dirCacheRef.current.delete(key);
}
}
}, []);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
right: false,
});
// Map connectionId → cache key, set at connect time so each tab's
// navigateTo can use the correct cache key even when multiple tabs
// share the same hostId with different session-time overrides.
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
// Store last connected host info for reconnection
const lastConnectedHostRef = useRef<{
left: Host | "local" | null;
@@ -149,10 +163,12 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane: createPane,
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
});
const {
@@ -173,6 +189,7 @@ export const useSftpState = (
renameFile,
changePermissions,
} = useSftpPaneActions({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -182,6 +199,7 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -249,12 +267,16 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
} = useSftpExternalOperations({
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload: options?.useCompressedUpload,
addExternalUpload,
updateExternalUpload,
@@ -298,6 +320,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -344,6 +367,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -396,6 +420,8 @@ export const useSftpState = (
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
@@ -407,7 +433,8 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
}), []); // Empty deps - these wrappers never change
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
// Return object with stable method references but reactive state
// State changes will cause re-renders, but method references stay stable

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a string value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists
* @param validate - Optional function to validate stored value; returns fallback if invalid
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
validate?: (value: string) => value is T,
) => {
const [value, setValue] = useState<T>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored === null) return fallback;
if (validate) return validate(stored) ? stored : fallback;
return stored as T;
});
useEffect(() => {
localStorageAdapter.writeString(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -90,7 +90,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionData(sessionId, cb);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
return bridge.onSessionExit(sessionId, cb);

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -56,7 +56,13 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(): UseUpdateCheckResult {
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
const autoUpdateEnabled = options?.autoUpdateEnabled ??
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
@@ -136,14 +142,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
return;
}
// 'available' means an update was found but auto-download is disabled.
// Surface the version info (hasUpdate + latestRelease) but keep
// autoDownloadStatus at 'idle' so the manual download path shows.
const isAvailableOnly = snapshot.status === 'available';
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
autoDownloadStatus: snapshot.status,
downloadPercent: snapshot.percent,
downloadError: snapshot.error,
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
downloadError: isAvailableOnly ? null : snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
@@ -186,15 +198,18 @@ export function useUpdateCheck(): UseUpdateCheckResult {
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
// When auto-update is disabled, autoDownload=false in the main process
// so no download will start. Don't transition to 'downloading' or the
// UI will be stuck at 0%. Keep status idle and let the manual download
// link surface instead.
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
// Only transition to 'downloading' if the user hasn't dismissed this
// version — otherwise leave the status at 'idle' so no download
// progress/ready toast appears for a release they don't want.
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
downloadPercent: isDismissed ? prev.downloadPercent : 0,
downloadError: isDismissed ? prev.downloadError : null,
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
downloadError: shouldTrackDownload ? null : prev.downloadError,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
@@ -439,6 +454,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && res?.available) {
// GitHub API failed but electron-updater found an update.
// Respect dismissed versions before surfacing.
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (res.version && res.version === dismissed) {
// User dismissed this version — don't re-surface
} else {
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'available',
hasUpdate: true,
error: null,
}));
}
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
@@ -519,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
@@ -533,12 +562,11 @@ export function useUpdateCheck(): UseUpdateCheckResult {
return;
}
// Check if we've checked recently
// Hydrate cached release info so update status is visible across windows.
// When auto-update is disabled, hydrate release data (for the Settings UI)
// but don't set hasUpdate (which would trigger the toast in App.tsx).
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
// Hydrate cached release info so late-opening windows show the result
if (lastCheck) {
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
@@ -556,6 +584,19 @@ export function useUpdateCheck(): UseUpdateCheckResult {
// Ignore corrupted cache
}
}
}
// Respect auto-update toggle — skip automatic check when disabled.
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
// autoUpdateEnabled dependency) can re-trigger this effect.
if (!autoUpdateEnabled) {
return;
}
// Check if we've checked recently
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
return;
}
@@ -563,6 +604,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(async () => {
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stillEnabled === 'false') {
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
return;
}
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
@@ -601,7 +649,7 @@ export function useUpdateCheck(): UseUpdateCheckResult {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, performCheck]);
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
return {
updateState,

View File

@@ -0,0 +1,830 @@
/**
* AIChatSidePanel - Main AI chat interface side panel
*
* Zed-style agent panel with agent selector, scoped chat sessions,
* message list, input area, and session history drawer.
*
* Core logic is decomposed into focused hooks:
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
* - useToolApproval: tool approval workflow, timeouts, resume logic
* - useConversationExport: export formats & object URL lifecycle
*/
import {
History,
Plus,
Trash2,
X,
} from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '../lib/utils';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIPermissionMode,
AISession,
AISessionScope,
ChatMessage,
DiscoveredAgent,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import { useToolApproval } from './ai/hooks/useToolApproval';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
interface AIChatSidePanelProps {
// Session state (per-scope)
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
setActiveSessionId: (scopeKey: string, id: string | null) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
updater: (msg: ChatMessage) => ChatMessage,
) => void;
updateMessageById: (
sessionId: string,
messageId: string,
updater: (msg: ChatMessage) => ChatMessage,
) => void;
// Provider config
providers: ProviderConfig[];
activeProviderId: string;
activeModelId: string;
// Agent info
defaultAgentId: string;
externalAgents: ExternalAgentConfig[];
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
agentModelMap: Record<string, string>;
setAgentModel: (agentId: string, modelId: string) => void;
// Safety
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
commandBlocklist?: string[];
maxIterations?: number;
// Web search
webSearchConfig?: WebSearchConfig | null;
// Context
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds?: string[];
scopeLabel?: string;
// Terminal session context (from parent)
terminalSessions?: Array<{
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
// Visibility
isVisible?: boolean;
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
}
if (message.role === 'assistant') {
const parts: string[] = [];
if (message.content) parts.push(message.content);
if (message.toolCalls?.length) {
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
content: `Tool result:\n${tr.content}`,
}));
}
return [];
});
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
setActiveSessionId: setActiveSessionIdForScope,
createSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
providers,
activeProviderId,
activeModelId,
defaultAgentId,
externalAgents,
setExternalAgents,
agentModelMap,
setAgentModel,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
maxIterations = 20,
webSearchConfig,
scopeType,
scopeTargetId,
scopeHostIds,
scopeLabel,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
}) => {
const { t } = useI18n();
// ── Per-scope state ──
// Derive scope key for per-scope isolation
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
// Per-scope input values
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
const inputValue = inputValueMap[scopeKey] ?? '';
const setInputValue = useCallback((val: string) => {
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
}, [scopeKey]);
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
const resolveExecutorContextRef = useRef(resolveExecutorContext);
resolveExecutorContextRef.current = resolveExecutorContext;
// ── Streaming hook ──
const {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
} = useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
});
// ── Tool approval hook ──
const {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
} = useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
});
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session) {
setCurrentAgentId(session.agentId);
}
}
}, [scopeKey, activeSessionId, sessions]);
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
// Sync provider configs to main process so it can decrypt API keys server-side.
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncProviders && providers.length > 0) {
void bridge.aiSyncProviders(providers);
}
}, [providers]);
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
// Preserve active streams across tab switches. The panel is conditionally
// mounted per tab, so unmounting here should not cancel in-flight work.
useEffect(() => {
return () => {
// no-op: stream lifecycle is managed by explicit stop/delete actions
};
}, []);
// Agent discovery
const {
discoveredAgents,
isDiscovering,
rediscover,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const handleEnableDiscoveredAgent = useCallback(
(agent: DiscoveredAgent) => {
const config = enableAgent(agent);
setExternalAgents?.((prev) => [...prev, config]);
},
[enableAgent, setExternalAgents],
);
// Active session (scoped)
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const messages = activeSession?.messages ?? [];
// ── Export hook ──
const { handleExport } = useConversationExport(activeSession);
// Active provider info
const activeProvider = useMemo(
() => providers.find((p) => p.id === activeProviderId),
[providers, activeProviderId],
);
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
return stored;
}
// Default to first preset; for models with thinking levels, use the default level
if (agentModelPresets.length > 0) {
const first = agentModelPresets[0];
if (first.thinkingLevels?.length) {
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
}
return first.id;
}
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Filtered sessions for history (matching current scope type)
const historySessions = useMemo(
() =>
sessions
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
.sort((a, b) => b.updatedAt - a.updatedAt),
[sessions, scopeType, scopeTargetId],
);
// -------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------
const handleNewChat = useCallback(() => {
const scope: AISessionScope = {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
};
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
setShowHistory(false);
setInputValue('');
}, [
scopeType,
scopeTargetId,
scopeHostIds,
currentAgentId,
createSession,
setActiveSessionId,
setInputValue,
]);
const handleOpenSettings = useCallback(() => {
void openSettingsWindow();
}, [openSettingsWindow]);
// -------------------------------------------------------------------
// Shared helpers for handleSend sub-flows
// -------------------------------------------------------------------
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const filesRef = useRef(files);
filesRef.current = files;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
const s = sessionsRef.current.find(x => x.id === sessionId);
if (s && (!s.title || s.title === 'New Chat')) {
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
}
}, [updateSessionTitle]);
const buildExecutorContextForScope = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}): ExecutorContext => {
const resolved = resolveExecutorContextRef.current?.(scope);
if (resolved) return resolved;
return {
sessions: terminalSessionsRef.current,
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
};
}, []);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
// -------------------------------------------------------------------
const handleSend = useCallback(async () => {
const trimmed = inputValueRef.current.trim();
const sendScopeKey = scopeKey;
if (!trimmed || isStreaming) return;
const isExternalAgent = currentAgentId !== 'catty';
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
const errSessionId = ensureSession();
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
setInputValue('');
return;
}
// Ensure session exists
const sessionId = ensureSession();
// Capture images before clearing
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearFiles();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
if (isExternalAgent) {
if (!agentConfig) {
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
setStreamingForScope(sessionId, false);
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
providers,
selectedAgentModel,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
}
// Clear any lingering statusText when the external agent stream finishes
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
setPendingApproval,
autoTitleSession,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
]);
const handleStop = useCallback(() => {
if (!activeSessionId) return;
const controller = abortControllersRef.current.get(activeSessionId);
controller?.abort();
abortControllersRef.current.delete(activeSessionId);
setStreamingForScope(activeSessionId, false);
// Clear statusText on the last message so stale status indicators disappear
updateLastMessage(activeSessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
// Also clear any pending approval (clears timeout too via setPendingApproval)
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
setPendingApproval(null);
}
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
const handleSelectSession = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
// Restore agent selector to match the session's bound agent
const session = sessions.find((s) => s.id === sessionId);
if (session) {
setCurrentAgentId(session.agentId);
}
setShowHistory(false);
},
[setActiveSessionId, sessions],
);
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
deleteSession(sessionId, scopeKey);
// Active session clearing is handled by deleteSession with scopeKey
},
[deleteSession, scopeKey],
);
const handleAgentChange = useCallback((agentId: string) => {
setCurrentAgentId(agentId);
// Preserve the current session in history and start a new one with the selected agent
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, agentId);
setActiveSessionId(session.id);
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
// -------------------------------------------------------------------
// Render
// -------------------------------------------------------------------
if (!isVisible) return null;
return (
<div className="flex flex-col h-full bg-background">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
session={activeSession}
onExport={handleExport}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
title="Session history"
>
<History size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
title="New chat"
>
<Plus size={15} />
</Button>
</div>
</div>
{/* ── Main content ── */}
{showHistory ? (
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
) : (
<>
{/* Chat messages */}
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
{messages.length === 0 && historySessions.length > 0 && (
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
)}
{/* Input area */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
</>
)}
</div>
);
};
// -------------------------------------------------------------------
// Session History Drawer
// -------------------------------------------------------------------
interface SessionHistoryDrawerProps {
sessions: AISession[];
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onDelete: (e: React.MouseEvent, sessionId: string) => void;
onClose: () => void;
}
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
sessions,
activeSessionId,
onSelect,
onDelete,
onClose,
}) => {
const { t } = useI18n();
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noSessions')}
</p>
</div>
) : (
sessions.map((session) => {
const isActive = session.id === activeSessionId;
const time = new Date(session.updatedAt);
const timeStr = formatRelativeTime(time, t);
return (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
)}
>
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
{session.title || t('ai.chat.untitled')}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[12px] text-muted-foreground/50">
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
);
};
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
function formatRelativeTime(date: Date, t: (key: string) => string): string {
const now = Date.now();
const diff = now - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1) return t('ai.chat.justNow');
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// -------------------------------------------------------------------
// Export
// -------------------------------------------------------------------
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
AIChatSidePanel.displayName = 'AIChatSidePanel';
export default AIChatSidePanel;
export { AIChatSidePanel };
export type { AIChatSidePanelProps };

View File

@@ -731,6 +731,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [webdavPassword, setWebdavPassword] = useState('');
const [webdavToken, setWebdavToken] = useState('');
const [showWebdavSecret, setShowWebdavSecret] = useState(false);
const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false);
const [webdavError, setWebdavError] = useState<string | null>(null);
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(null);
const [isSavingWebdav, setIsSavingWebdav] = useState(false);
@@ -853,6 +854,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
setWebdavUsername(config?.username || '');
setWebdavPassword(config?.password || '');
setWebdavToken(config?.token || '');
setWebdavAllowInsecure(config?.allowInsecure || false);
setShowWebdavSecret(false);
setWebdavError(null);
setWebdavErrorDetail(null);
@@ -903,6 +905,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(),
password: webdavAuthType === 'token' ? undefined : webdavPassword,
token: webdavAuthType === 'token' ? webdavToken.trim() : undefined,
allowInsecure: webdavAllowInsecure ? true : undefined,
};
setIsSavingWebdav(true);
@@ -975,6 +978,10 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
onApplyPayload(result.mergedPayload);
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
// Conflict modal will show automatically
@@ -1337,6 +1344,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
{t('cloudSync.webdav.showSecret')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={webdavAllowInsecure}
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.webdav.allowInsecure')}
</label>
{webdavError && (
<p className="text-sm text-red-500">{webdavError}</p>
)}

View File

@@ -18,6 +18,10 @@ export const DISTRO_LOGOS: Record<string, string> = {
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -34,6 +38,10 @@ export const DISTRO_COLORS: Record<string, string> = {
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
default: "bg-slate-600",
};

View File

@@ -27,6 +27,7 @@ import {
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { customThemeStore } from "../application/state/customThemeStore";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
@@ -99,6 +100,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const { terminalThemeId, terminalFontSize } = useSettingsState();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -113,7 +115,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
os: "linux",
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark",
theme: terminalThemeId,
fontSize: terminalFontSize,
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),

View File

@@ -61,6 +61,7 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
@@ -89,6 +90,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => {
if (!node.children) return [];
@@ -171,7 +173,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
{hostsCountInNode}
</span>
)}
</div>

View File

@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="start" alignToParent>
<DropdownContent className="w-48" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"

View File

@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const RENDER_LIMIT = 100; // Limit rendered items for performance
// Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async () => {
const handleScanSystem = useCallback(async (silent = false) => {
setIsScanning(true);
try {
const content = await readKnownHosts();
if (content === undefined) {
toast.error(
if (!silent) toast.error(
t("knownHosts.toast.scanUnavailable"),
t("vault.nav.knownHosts"),
);
return;
}
if (!content) {
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
return;
}
const parsed = parseKnownHostsFile(content);
if (parsed.length === 0) {
toast.info(
if (!silent) toast.info(
t("knownHosts.toast.scanNoEntries"),
t("vault.nav.knownHosts"),
);
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
if (newHosts.length > 0) {
onImportFromFile(newHosts);
toast.success(
if (!silent) toast.success(
t("knownHosts.toast.scanImported", { count: newHosts.length }),
t("vault.nav.knownHosts"),
);
} else {
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
}
} catch (err) {
logger.error("Failed to scan system known_hosts:", err);
toast.error(
if (!silent) toast.error(
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
t("vault.nav.knownHosts"),
);
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
}
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
// Auto-scan on first mount
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
useEffect(() => {
if (!hasScannedRef.current) {
hasScannedRef.current = true;
// Delay scan slightly to not block initial render
const timer = setTimeout(() => {
handleScanSystem();
handleScanSystem(true);
}, 100);
return () => clearTimeout(timer);
}
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
variant="ghost"
size="sm"
className="h-9 px-3 text-xs"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw

View File

@@ -130,7 +130,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {

View File

@@ -24,7 +24,6 @@ import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActi
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
import { Dialog, DialogContent } from "./ui/dialog";
interface SFTPModalProps {
host: Host;
@@ -649,10 +648,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
};
if (!open) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<>
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
<SftpModalHeader
onClose={handleClose}
t={t}
host={host}
credentials={credentials}
@@ -753,7 +755,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</DialogContent>
</div>
<SftpModalDialogs
t={t}
@@ -808,7 +810,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,221 @@
/**
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
*
* Shows snippets organized by package hierarchy with breadcrumb navigation.
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
isVisible?: boolean;
}
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
onSnippetClick,
isVisible = true,
}) => {
const { t } = useI18n();
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
const results: { name: string; path: string; count: number }[] = [];
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1);
return cleanPath.split('/')[0];
})
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
const displayedSnippets = useMemo(() => {
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter(sn =>
sn.label.toLowerCase().includes(s) ||
sn.command.toLowerCase().includes(s)
);
}
return result;
}, [snippets, selectedPackage, search]);
// Also filter packages by search when at root level
const filteredPackages = useMemo(() => {
if (!search.trim()) return displayedPackages;
const s = search.toLowerCase();
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
}, [displayedPackages, search]);
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
<button
className={cn(
"hover:text-primary transition-colors truncate",
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
)}
onClick={() => setSelectedPackage(null)}
>
{t('terminal.toolbar.library')}
</button>
{breadcrumb.map((b) => (
<React.Fragment key={b.path}>
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
<button
className="text-muted-foreground hover:text-primary transition-colors truncate"
onClick={() => setSelectedPackage(b.path)}
>
{b.name}
</button>
</React.Fragment>
))}
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
{!hasAnyContent && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
)}
{/* Packages */}
{filteredPackages.map((pkg) => (
<button
key={pkg.path}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
>
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Package size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{pkg.name}</div>
<div className="text-[10px] text-muted-foreground">
{t('snippets.package.count', { count: pkg.count })}
</div>
</div>
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
</button>
))}
{/* Snippets */}
{displayedSnippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
{s.command}
</span>
</button>
))}
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
</div>
);
};
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -2,13 +2,14 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
@@ -16,18 +17,41 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
class AITabErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
<div>{this.state.error.message}</div>
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
</div>
);
}
return this.props.children;
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsSyncTabWithVault: React.FC = () => {
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
keys,
@@ -66,6 +90,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
onSettingsApplied={onSettingsApplied}
/>
);
};
@@ -73,7 +98,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const aiState = useAIState();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -152,6 +178,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
</TabsTrigger>
<TabsTrigger
value="ai"
className="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"
>
<Sparkles size={14} /> AI
</TabsTrigger>
<TabsTrigger
value="sync"
className="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"
@@ -228,9 +260,40 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<SettingsFileAssociationsTab />
)}
{mountedTabs.has("ai") && (
<AITabErrorBoundary>
<React.Suspense fallback={null}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault />
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
)}
@@ -247,6 +310,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
globalHotkeyEnabled={settings.globalHotkeyEnabled}
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
autoUpdateEnabled={settings.autoUpdateEnabled}
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}

View File

@@ -0,0 +1,618 @@
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
* to provide a unified SFTP experience. Renders a single pane (left side only).
*
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
* the main SftpView tab. Instead manages pane visibility internally.
*
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
import { SftpOverlays } from "./sftp/SftpOverlays";
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
hostId: string;
connectionKey: string;
targetPath?: string;
entries: DropEntry[];
} | null;
onPendingUploadHandled?: (requestId: string) => void;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
keys,
identities,
updateHosts,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
}) => {
const { t } = useI18n();
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
toast.error(t('sftp.autoSync.error', { error: payload.error }));
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
// Auto-connect when activeHost changes.
// Uses sftpRef to avoid re-triggering on every sftp state change.
const connectedKeyRef = useRef<string | null>(null);
// Store the Host object used for the current connection so the header
// can show session-time overrides even during deferred host switches.
const connectedHostObjRef = useRef<Host | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const pendingConnectionKeyRef = useRef<string | null>(null);
const prevIsVisibleRef = useRef(isVisible);
// Reset location guard when the panel is reopened so the terminal cwd
// is re-applied even if it matches the previous session's path.
useEffect(() => {
if (isVisible && !prevIsVisibleRef.current) {
lastAppliedInitialLocationKeyRef.current = null;
}
prevIsVisibleRef.current = isVisible;
}, [isVisible]);
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd();
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd]);
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
const hasActiveTransfers = useMemo(
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
[sftp.transfers],
);
// Block host-following while any connection-sensitive UI or operation
// is active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
if (!activeHost) return;
const s = sftpRef.current;
// Serial terminals don't support SFTP — disconnect any existing
// connection (remote or local) so the panel doesn't remain bound to
// a previous host.
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
// Serial terminals don't support SFTP. Just clear the tracked
// connection key so switching back to a remote terminal will
// trigger auto-connect. Don't disconnect existing tabs — they
// may be reused when focus returns.
connectedKeyRef.current = null;
return;
}
// Local terminals connect to the local file browser
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
if (hasActiveWork) return;
const leftConn = s.leftPane.connection;
if (leftConn?.isLocal) {
// Already connected locally
connectedKeyRef.current = "local";
return;
}
// Check for an existing local tab to reuse
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
tab.connection?.isLocal && tab.connection.status === "connected",
);
if (existingLocalTab) {
s.selectTab("left", existingLocalTab.id);
connectedKeyRef.current = "local";
return;
}
connectedKeyRef.current = "local";
// Preserve existing remote tab when switching to local
const needsNewTab = !!(leftConn && leftConn.status === "connected");
if (needsNewTab) {
s.connect("left", "local", { forceNewTab: true });
} else if (leftConn) {
// Await disconnect before connecting locally to avoid the async
// disconnect wiping out the fresh local connection.
void s.disconnect("left").then(() => s.connect("left", "local"));
} else {
s.connect("left", "local");
}
return;
}
// Build a connection key that accounts for session-time overrides
// (same host ID may have different port/protocol in different workspace panes).
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
if (connectedKeyRef.current === connectionKey) return;
// Don't switch connections while transfers or editor are active
if (hasActiveWork) return;
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
hostLabel: activeHost.label,
protocol: activeHost.protocol,
hostname: activeHost.hostname,
});
// Check if an existing SFTP tab matches this exact endpoint.
// We track which connectionKey was used to create each tab so that
// tabs for the same host ID with different session-time overrides
// (port/protocol) are not incorrectly reused.
const tabs = s.leftTabs.tabs;
const existingTab = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
// Don't reuse errored tabs — they need a fresh connection
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
});
if (existingTab) {
s.selectTab("left", existingTab.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
return;
}
// Create a new tab when there's already an active connection to a different
// host, so the previous tab is preserved for instant switching on focus change.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
// Store the pending key so the effect below can map it once the tab is created
pendingConnectionKeyRef.current = connectionKey;
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
// Track the active tab's connectionKey after connect() creates or reuses it.
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
useEffect(() => {
const activeTabId = sftp.leftTabs.activeTabId;
if (activeTabId && pendingConnectionKeyRef.current) {
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
pendingConnectionKeyRef.current = null;
}
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
// so they stop when the session disconnects.
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
connectedKeyRef.current = null;
if (sftp.activeFileWatchCountRef) {
sftp.activeFileWatchCountRef.current = 0;
}
}
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
useEffect(() => {
if (!activeHost || !initialLocation) return;
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
const activePane = sftpRef.current.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
// Include full endpoint key so that same-hostId sessions with
// different overrides each get their initial location applied.
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
if (connection.currentPath === initialLocation.path) {
lastAppliedInitialLocationKeyRef.current = locationKey;
return;
}
lastAppliedInitialLocationKeyRef.current = locationKey;
sftpRef.current.navigateTo("left", initialLocation.path);
}, [
activeHost,
initialLocation,
sftp.leftPane,
]);
useEffect(() => {
if (!pendingUpload || !activeHost) return;
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
if (pendingUpload.hostId !== activeHost.id) return;
const activePane = sftp.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
handledPendingUploadIdRef.current = pendingUpload.requestId;
const runUpload = async () => {
try {
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
targetPath: pendingUpload.targetPath,
});
if (results.some((result) => result.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
const successCount = results.filter((result) => result.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
handledPendingUploadIdRef.current = null;
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
return;
} finally {
onPendingUploadHandled?.(pendingUpload.requestId);
}
};
void runUpload();
}, [
activeHost,
onPendingUploadHandled,
pendingUpload,
sftp.leftPane,
t,
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
[sftp.transfers],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
const connection = sftpRef.current.leftPane.connection;
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
if (!connection || connection.isLocal) return false;
if (task.targetHostId) {
if (connection.hostId !== task.targetHostId) return false;
// If the transfer recorded a full endpoint key, use it to
// distinguish same-hostId uploads with different session overrides.
if (task.targetConnectionKey) {
return connectedKeyRef.current === task.targetConnectionKey;
}
return true;
}
return connection.id === task.targetConnectionId;
},
[sftp.leftPane.connection],
);
// When the auto-connect effect defers a switch (active transfers or open
// editor), the panel still operates on the current connection, not
// activeHost. Use the connected host for the header so the label matches
// what browse/edit/delete actions actually target.
const displayHost = useMemo(() => {
const conn = sftp.leftPane.connection;
if (conn && !conn.isLocal) {
// Prefer the stored Host object from connect time — it preserves
// session-time overrides that the vault host may lack.
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
return connectedHostObjRef.current;
}
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
}
return activeHost;
}, [sftp.leftPane.connection, hosts, activeHost]);
// Determine the active pane to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
return (
<SftpContextProvider
hosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
>
<div
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}
fallback={displayHost.label.slice(0, 2).toUpperCase()}
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
>
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
</div>
</div>
)}
{/* File browser pane - render only the active pane */}
<div className="relative flex-1 min-h-0">
{leftPanes.map((pane, idx) => {
// Manage visibility locally instead of via activeTabStore
const isActive = activeLeftPaneId
? pane.id === activeLeftPaneId
: idx === 0;
if (!isActive) return null;
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="left"
pane={pane}
showHeader
showEmptyHeader
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
</div>
);
})}
</div>
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
</div>
{renderOverlays && (
<SftpOverlays
hosts={hosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
hostSearchRight={hostSearchRight}
setShowHostPickerLeft={setShowHostPickerLeft}
setShowHostPickerRight={setShowHostPickerRight}
setHostSearchLeft={setHostSearchLeft}
setHostSearchRight={setHostSearchRight}
handleHostSelectLeft={handleHostSelectLeft}
handleHostSelectRight={handleHostSelectRight}
permissionsState={permissionsState}
setPermissionsState={setPermissionsState}
showTextEditor={showTextEditor}
setShowTextEditor={setShowTextEditor}
textEditorTarget={textEditorTarget}
setTextEditorTarget={setTextEditorTarget}
textEditorContent={textEditorContent}
setTextEditorContent={setTextEditorContent}
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
setFileOpenerTarget={setFileOpenerTarget}
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
t={t}
/>
)}
</SftpContextProvider>
);
};
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onBulkSave: (snippets: Snippet[]) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hotkeyScheme,
keyBindings,
onSave,
onBulkSave,
onDelete,
onPackagesChange,
onRunSnippet,
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
});
setRightPanelMode('none');
}
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Update packages first, then save snippets
onPackagesChange(keep);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Bulk-save all snippets to avoid stale-closure overwrites
onBulkSave(updatedSnippets);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
if (selectedPackage === source) setSelectedPackage(newPath);
};
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
// Validate: duplicate (case-insensitive), excluding the package being renamed
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* No Auto Run */}
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={editingSnippet.noAutoRun ?? false}
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -4,8 +4,8 @@ import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -28,7 +28,7 @@ import {
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
@@ -118,15 +118,13 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
serialConfig?: SerialConfig;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onSessionExit?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCloseSession?: (sessionId: string) => void;
@@ -141,11 +139,21 @@ interface TerminalProps {
) => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onOpenSftp?: (
host: Host,
initialPath?: string,
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onOpenScripts?: () => void;
onOpenTheme?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
// Helper function to format network speed (bytes/sec) to human-readable format
@@ -179,10 +187,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
noAutoRun,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
hotkeyScheme = "disabled",
keyBindings = [],
onHotkeyAction,
@@ -197,11 +203,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
onOpenScripts,
onOpenTheme,
isBroadcastEnabled,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -228,26 +238,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// Global and host-level highlights are independent:
// global toggle controls global rules, host toggle controls host-specific rules
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
@@ -279,7 +291,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
// isScriptsOpen state removed - scripts now handled by side panel
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
@@ -288,7 +300,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -304,7 +315,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Drag and drop state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
@@ -366,6 +377,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
// Reject if terminal is not visible (background tab) — user can't see the prompt
if (!isVisibleRef.current) return Promise.resolve(false);
// Reject if another prompt is already pending (avoid resolver overwrite)
if (osc52ReadResolverRef.current) return Promise.resolve(false);
return new Promise((resolve) => {
osc52ReadResolverRef.current = resolve;
setOsc52ReadPromptVisible(true);
});
}, []);
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
setOsc52ReadPromptVisible(false);
osc52ReadResolverRef.current?.(allowed);
osc52ReadResolverRef.current = null;
// Restore focus to terminal
termRef.current?.focus();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -422,6 +454,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
resolvedChainHosts,
sessionId,
startupCommand,
noAutoRun,
terminalSettings,
terminalSettingsRef,
terminalBackend,
@@ -457,6 +490,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
sessionLog,
});
sessionStartersRef.current = sessionStarters;
@@ -497,6 +531,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onOsc52ReadRequest: handleOsc52ReadRequest,
});
xtermRuntimeRef.current = runtime;
@@ -511,12 +546,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
@@ -642,12 +679,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth, host.protocol, host.hostname]);
const safeFit = () => {
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) return;
const container = containerRef.current;
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) {
// Terminal is hidden — invalidate the cached size so that when it
// becomes visible again, a non-forced fit won't be suppressed by a
// stale size match (e.g. after font metrics changed while hidden).
lastFittedSizeRef.current = null;
return;
}
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
return;
}
}
const runFit = () => {
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
} catch (err) {
logger.warn("Fit failed", err);
@@ -721,7 +780,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
@@ -739,14 +798,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
@@ -828,17 +887,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
if (isResizing) return;
if (isResizing || !isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
});
@@ -853,7 +912,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (prevIsResizingRef.current && !isResizing && isVisible) {
const timer = setTimeout(() => {
safeFit();
safeFit({ force: true, requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}
@@ -863,7 +922,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
@@ -903,7 +962,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
term.onSelectionChange(onSelectionChange);
const disposable = term.onSelectionChange(onSelectionChange);
return () => disposable.dispose();
}, [terminalSettings?.copyOnSelect]);
// Track whether the terminal application has enabled mouse tracking
@@ -965,11 +1025,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (!isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
};
@@ -1001,18 +1062,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scrollOnPasteRef,
});
const handleSnippetClick = (cmd: string) => {
if (sessionRef.current) {
const payload = `${cmd}\r`;
terminalBackend.writeToSession(sessionRef.current, payload);
scrollToBottomAfterProgrammaticInput(payload);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
}
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
if (sessionRef.current) {
@@ -1021,30 +1070,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
onOpenSftp(host, initialPath, undefined, sessionId);
return;
}
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
@@ -1176,27 +1223,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.focus();
}
} else {
// Remote terminal: Trigger SFTP upload
// Get current working directory for SFTP initial path
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
// Remote terminal: Trigger SFTP upload via parent
if (onOpenSftp) {
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
} catch {
// Silently fail and open SFTP without initial path
}
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
setPendingUploadEntries(dropEntries);
// Use flushSync to ensure sftpInitialPath is updated synchronously
// before setShowSFTP(true) triggers the modal open
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
}
} catch (error) {
logger.error("Failed to handle file drop", error);
@@ -1207,18 +1248,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
snippets={snippets}
host={host}
defaultThemeId={terminalTheme.id}
defaultFontFamilyId={fontFamilyId}
defaultFontSize={fontSize}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onSnippetClick={handleSnippetClick}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
@@ -1652,7 +1685,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
<div
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
>
<div
@@ -1677,6 +1710,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
onKeyDown={(e) => {
if (e.key === 'Escape') handleOsc52ReadResponse(false);
}}
>
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
{t("terminal.osc52.readPrompt.deny")}
</Button>
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
{t("terminal.osc52.readPrompt.allow")}
</Button>
</div>
</div>
</div>
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
@@ -1742,78 +1798,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
themeColors={effectiveTheme.colors}
/>
)}
<SFTPModal
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => allHosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
username: resolvedAuth.username,
hostname: host.hostname,
port: host.port,
password: resolvedAuth.password,
privateKey: resolvedAuth.key?.privateKey,
certificate: resolvedAuth.key?.certificate,
passphrase: resolvedAuth.passphrase,
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}
onClose={() => {
setShowSFTP(false);
setPendingUploadEntries([]);
}}
initialPath={sftpInitialPath}
initialEntriesToUpload={pendingUploadEntries}
onUpdateHost={onUpdateHost}
/>
</div>
</TerminalContextMenu>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
@@ -16,6 +18,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
interface TopTabsProps {
theme: 'dark' | 'light';
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
workspaces: Workspace[];
@@ -38,6 +41,82 @@ interface TopTabsProps {
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
}
// Detect local OS for local terminal tab icons
const localOsId = (() => {
if (typeof navigator === 'undefined') return 'linux';
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macos';
if (/Win/i.test(ua)) return 'windows';
return 'linux';
})();
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
return (
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
<Usb className={iconSize} />
</div>
);
}
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
const logo = DISTRO_LOGOS[localOsId];
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
if (logo) {
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={localOsId}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<TerminalSquare className={iconSize} />
</div>
);
}
// Try distro logo with brand background color
if (host) {
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={host.distro || host.os}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
}
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const tone = status === 'connected'
? "bg-emerald-400"
@@ -56,12 +135,19 @@ const WindowControls: React.FC = memo(() => {
// Check initial maximized state
fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state
// Listen for window resize to update maximized state (debounced to avoid IPC storm)
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
}, 200);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
};
}, [fetchIsMaximized]);
const handleMinimize = () => {
@@ -78,17 +164,17 @@ const WindowControls: React.FC = memo(() => {
};
return (
<div className="flex items-center app-drag">
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -101,7 +187,7 @@ const WindowControls: React.FC = memo(() => {
</button>
<button
onClick={handleClose}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
title="Close"
>
<X size={16} />
@@ -113,6 +199,7 @@ WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
hosts,
sessions,
orphanSessions,
workspaces,
@@ -235,6 +322,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [logViews]);
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -376,26 +469,29 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
activeTabId === session.id
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
</div>
@@ -445,23 +541,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
@@ -495,12 +594,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
data-tab-id={logView.id}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-colors duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<span className="truncate">
@@ -536,36 +640,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
className="relative w-full bg-secondary border-b border-border/60 app-drag"
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 flex items-center gap-2 app-drag"
className="h-9 flex items-end gap-0 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div
onClick={() => onSelectTab('vault')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isVaultActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
<Shield size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isSftpActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
<Folder size={14} /> SFTP
</div>
</div>
@@ -594,7 +703,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{renderOrderedTabs()}
@@ -603,7 +712,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -611,7 +720,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
@@ -628,7 +737,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -637,7 +746,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Bell size={16} />
</Button>
@@ -653,9 +771,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
</div>
</div>
);
@@ -665,6 +783,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
return (
prev.theme === next.theme &&
prev.hosts === next.hosts &&
prev.sessions === next.sessions &&
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&

View File

@@ -152,7 +152,7 @@ const TrayPanelContent: React.FC = () => {
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);

View File

@@ -689,6 +689,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
],
);
const countAllHostsInNode = useCallback((node: GroupNode): number => {
let count = node.hosts.length;
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
node.totalHostCount = count;
return count;
}, []);
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
const root: Record<string, GroupNode> = {};
const insertPath = (path: string, host?: Host) => {
@@ -712,9 +721,32 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
customGroups.forEach((path) => insertPath(path));
hosts.forEach((host) => insertPath(host.group || "General", host));
return root;
}, [hosts, customGroups]);
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [hosts, customGroups, countAllHostsInNode]);
// Generate all possible group paths from the tree (including all intermediate nodes)
const allGroupPaths = useMemo(() => {
const paths = new Set<string>();
const traverse = (nodes: Record<string, GroupNode>) => {
Object.values(nodes).forEach((node) => {
if (node.path) {
paths.add(node.path);
}
if (node.children) {
traverse(node.children);
}
});
};
// Traverse the tree
traverse(buildGroupTree);
return Array.from(paths).sort();
}, [buildGroupTree]);
const findGroupNode = (path: string | null): GroupNode | null => {
if (!path)
@@ -876,8 +908,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
insertPath(host.group, host);
}
});
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [treeViewHosts, customGroups]);
}, [treeViewHosts, customGroups, countAllHostsInNode]);
// Create tree view specific group tree that excludes ungrouped hosts
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
@@ -1718,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.hosts.length })}
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
</div>
@@ -2170,6 +2205,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: [...snippets, s],
)
}
onBulkSave={onUpdateSnippets}
onDelete={(id) =>
onUpdateSnippets(snippets.filter((s) => s.id !== id))
}
@@ -2270,12 +2306,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
@@ -2310,12 +2341,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),

View File

@@ -0,0 +1,89 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
{...props}
/>
);
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
icon?: ReactNode;
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleClick = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
if (isAtBottom) return null;
return (
<button
type="button"
className={cn(
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
'flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
'shadow-sm',
className,
)}
onClick={handleClick}
{...props}
>
<ArrowDown size={14} />
</button>
);
};

View File

@@ -0,0 +1,87 @@
import { cn } from '../../lib/utils';
import { cjk } from '@streamdown/cjk';
import { code } from '@streamdown/code';
import type { ComponentProps, HTMLAttributes } from 'react';
import { memo } from 'react';
import { Streamdown } from 'streamdown';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-1.5',
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
className,
)}
{...props}
>
{children}
</div>
);
const streamdownPlugins = { cjk, code };
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
'[&_li]:my-0.5',
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
'[&_a]:text-primary [&_a]:underline',
'[&_hr]:border-border/30 [&_hr]:my-3',
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = 'MessageResponse';

View File

@@ -0,0 +1,283 @@
/**
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
*
* Simplified: no file attachments, screenshots, drag-drop, command palette,
* hover cards, referenced sources, or tabs. Core input + footer + submit.
*/
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
ReactNode,
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '../ui/input-group';
import { Spinner } from '../ui/spinner';
// ---------------------------------------------------------------------------
// PromptInput (form wrapper)
// ---------------------------------------------------------------------------
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
}
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
({ className, onSubmit, children, ...props }, ref) => {
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const textarea = form.querySelector('textarea');
const text = textarea?.value?.trim() ?? '';
if (!text) return;
onSubmit(text, e);
},
[onSubmit],
);
return (
<form ref={ref} onSubmit={handleSubmit} className={className} {...props}>
<InputGroup>{children}</InputGroup>
</form>
);
},
);
PromptInput.displayName = 'PromptInput';
// ---------------------------------------------------------------------------
// PromptInputTextarea
// ---------------------------------------------------------------------------
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
/** Called when Enter is pressed (without Shift) to trigger form submit */
onSubmitRequest?: () => void;
}
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
const internalRef = useRef<HTMLTextAreaElement | null>(null);
const setRef = useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
// CJK composition guard
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmitRequest?.();
// Trigger form submit
const form = internalRef.current?.closest('form');
if (form) {
form.requestSubmit();
}
}
},
[onKeyDown, onSubmitRequest],
);
return (
<InputGroupTextarea
ref={setRef}
className={className}
onKeyDown={handleKeyDown}
{...props}
/>
);
},
);
PromptInputTextarea.displayName = 'PromptInputTextarea';
// ---------------------------------------------------------------------------
// PromptInputFooter
// ---------------------------------------------------------------------------
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
({ className, ...props }, ref) => (
<InputGroupAddon
ref={ref}
align="block-end"
className={cn('gap-1', className)}
{...props}
/>
),
);
PromptInputFooter.displayName = 'PromptInputFooter';
// ---------------------------------------------------------------------------
// PromptInputTools (left side of footer)
// ---------------------------------------------------------------------------
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center gap-0.5', className)}
{...props}
/>
),
);
PromptInputTools.displayName = 'PromptInputTools';
// ---------------------------------------------------------------------------
// PromptInputButton (toolbar button with optional tooltip)
// ---------------------------------------------------------------------------
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
tooltip?: ReactNode;
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
}
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
const button = <InputGroupButton ref={ref} {...props} />;
if (!tooltip) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputButton.displayName = 'PromptInputButton';
// ---------------------------------------------------------------------------
// PromptInputSubmit
// ---------------------------------------------------------------------------
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
status?: PromptInputStatus;
onStop?: () => void;
}
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
const isRunning = status === 'submitted' || status === 'streaming';
const handleClick = useCallback(() => {
if (isRunning && onStop) {
onStop();
}
}, [isRunning, onStop]);
const icon =
status === 'submitted' ? (
<Spinner size={14} />
) : status === 'streaming' ? (
<Square size={14} />
) : status === 'error' ? (
<X size={14} />
) : (
<ArrowUp size={14} />
);
const tooltipLabel =
status === 'submitted'
? 'Waiting...'
: status === 'streaming'
? 'Stop'
: status === 'error'
? 'Error'
: 'Send';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
ref={ref}
type={isRunning ? 'button' : 'submit'}
onClick={isRunning ? handleClick : undefined}
variant="ghost"
disabled={disabled && !isRunning}
className={cn(
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
isRunning
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
: disabled
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
className,
)}
{...props}
>
{icon}
</InputGroupButton>
</TooltipTrigger>
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
// ---------------------------------------------------------------------------
// PromptInputSelect (thin wrappers around the project's Select component)
// ---------------------------------------------------------------------------
export const PromptInputSelect = Select;
export const PromptInputSelectTrigger = forwardRef<
ElementRef<typeof SelectTrigger>,
ComponentPropsWithoutRef<typeof SelectTrigger>
>(({ className, ...props }, ref) => (
<SelectTrigger
ref={ref}
className={cn(
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
'text-muted-foreground/40 hover:text-muted-foreground/70',
'focus:ring-0 focus:ring-offset-0',
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
className,
)}
{...props}
/>
));
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
export const PromptInputSelectContent = SelectContent;
export const PromptInputSelectItem = SelectItem;
export const PromptInputSelectValue = SelectValue;

View File

@@ -0,0 +1,76 @@
import { cn } from '../../lib/utils';
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;
isLoading?: boolean;
isInterrupted?: boolean;
}
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
const [expanded, setExpanded] = useState(false);
const statusIcon = isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
) : null;
return (
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
<button
type="button"
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
<span className="flex-1" />
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{result !== undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
<pre className={cn(
'text-[11px] font-mono whitespace-pre-wrap break-all',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
{isInterrupted && result === undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
<div className="text-[11px] text-muted-foreground/50">
Interrupted
</div>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,220 @@
import React from 'react';
import { cn } from '../../lib/utils';
type AgentLike = {
id?: string;
name?: string;
type?: 'builtin' | 'external';
icon?: string;
command?: string;
};
type AgentIconKey =
| 'catty'
| 'openai'
| 'claude'
| 'anthropic'
| 'gemini'
| 'google'
| 'ollama'
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'plus';
type AgentIconVisual = {
src: string;
badgeClassName: string;
imageClassName: string;
};
const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
catty: {
src: '/ai/agents/catty.svg',
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
claude: {
src: '/ai/agents/claude.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
anthropic: {
src: '/ai/providers/anthropic.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
gemini: {
src: '/ai/agents/gemini.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
google: {
src: '/ai/providers/google.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
ollama: {
src: '/ai/providers/ollama.svg',
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
openrouter: {
src: '/ai/providers/openrouter.svg',
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
zed: {
src: '/ai/agents/zed.svg',
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
atom: {
src: '/ai/agents/atom.svg',
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
terminal: {
src: '/ai/agents/terminal.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
plus: {
src: '/ai/agents/plus.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
},
};
function normalizeToken(value?: string): string {
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
}
function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (agent === 'add-more') {
return 'plus';
}
if (agent.type === 'builtin') {
return 'catty';
}
const tokens = [
normalizeToken(agent.icon),
normalizeToken(agent.command),
normalizeToken(agent.name),
normalizeToken(agent.id),
].filter(Boolean);
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
if (
tokens.some(
(token) =>
token.includes('codex') ||
token.includes('openai') ||
token.includes('chatgpt'),
)
) {
return 'openai';
}
if (
tokens.some(
(token) =>
token.includes('gemini') ||
token.includes('google') ||
token.includes('googlegemini'),
)
) {
return 'gemini';
}
if (tokens.some((token) => token.includes('ollama'))) {
return 'ollama';
}
if (tokens.some((token) => token.includes('openrouter'))) {
return 'openrouter';
}
if (tokens.some((token) => token.includes('zed'))) {
return 'zed';
}
if (tokens.some((token) => token.includes('factory'))) {
return 'atom';
}
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'
: size === 'sm'
? 'h-7 w-7 rounded-lg'
: size === 'lg'
? 'h-10 w-10 rounded-xl'
: 'h-8 w-8 rounded-lg';
const imageSize =
size === 'xs'
? 'h-3.5 w-3.5'
: size === 'sm'
? 'h-3.5 w-3.5'
: size === 'lg'
? 'h-5 w-5'
: 'h-4 w-4';
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
/>
);
}
return (
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,
visual.badgeClassName,
className,
)}
>
<img
src={visual.src}
alt=""
aria-hidden="true"
draggable={false}
className={cn(imageSize, visual.imageClassName)}
/>
</div>
);
};
export default AgentIconBadge;

View File

@@ -0,0 +1,280 @@
/**
* AgentSelector - Dropdown for switching between AI agents
*
* Dark, grouped agent menu with local SVG branding for built-in,
* discovered, and external agents.
*/
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
Dropdown,
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
interface AgentSelectorProps {
currentAgentId: string;
externalAgents: ExternalAgentConfig[];
discoveredAgents?: DiscoveredAgent[];
isDiscovering?: boolean;
onSelectAgent: (agentId: string) => void;
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
onRediscover?: () => void;
onManageAgents?: () => void;
}
const BUILTIN_AGENTS: AgentInfo[] = [
{
id: 'catty',
name: 'Catty Agent',
type: 'builtin',
description: 'Built-in terminal assistant',
available: true,
},
];
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
{children}
</span>
{action}
</div>
);
const AgentMenuRow: React.FC<{
agent: AgentInfo;
isActive?: boolean;
subtitle?: string;
onClick: () => void;
}> = ({ agent, isActive, subtitle, onClick }) => {
return (
<button
onClick={onClick}
className={cn(
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
isActive && 'bg-muted',
)}
>
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
<div className="min-w-0 flex-1">
<span className="block truncate">{agent.name}</span>
{subtitle && (
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
)}
</div>
</button>
);
};
const DiscoveredAgentRow: React.FC<{
agent: DiscoveredAgent;
onEnable: () => void;
}> = ({ agent, onEnable }) => {
const agentLike: AgentInfo = {
id: `discovered_${agent.command}`,
name: agent.name,
type: 'external',
icon: agent.icon,
command: agent.command,
available: true,
};
return (
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
<div className="min-w-0 flex-1">
<span className="block truncate text-foreground/86">{agent.name}</span>
<span className="block truncate text-[10px] text-muted-foreground/40">
{agent.version || agent.path}
</span>
</div>
<button
onClick={onEnable}
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
title={`Enable ${agent.name}`}
>
<Plus size={12} />
</button>
</div>
);
};
const AgentSelector: React.FC<AgentSelectorProps> = ({
currentAgentId,
externalAgents,
discoveredAgents = [],
isDiscovering = false,
onSelectAgent,
onEnableDiscoveredAgent,
onRediscover,
onManageAgents,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const enabledExternalAgents = useMemo(
() =>
externalAgents
.filter((agent) => agent.enabled)
.map(
(agent): AgentInfo => ({
id: agent.id,
name: agent.name,
type: 'external',
icon: agent.icon,
command: agent.command,
args: agent.args,
available: true,
}),
),
[externalAgents],
);
// Discovered agents not yet added to external agents
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
),
[discoveredAgents, externalAgents],
);
const allAgents = useMemo(
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
[enabledExternalAgents],
);
const currentAgent = useMemo(
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
[allAgents, currentAgentId],
);
const handleSelect = useCallback(
(agentId: string) => {
onSelectAgent(agentId);
setOpen(false);
},
[onSelectAgent],
);
const handleEnableDiscovered = useCallback(
(agent: DiscoveredAgent) => {
onEnableDiscoveredAgent?.(agent);
// After enabling, auto-select it
const agentId = `discovered_${agent.command}`;
onSelectAgent(agentId);
setOpen(false);
},
[onEnableDiscoveredAgent, onSelectAgent],
);
const handleManageAgents = useCallback(() => {
setOpen(false);
onManageAgents?.();
}, [onManageAgents]);
return (
<Dropdown open={open} onOpenChange={setOpen}>
<DropdownTrigger asChild>
<button
type="button"
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
>
<AgentIconBadge
agent={currentAgent}
size="xs"
variant="plain"
className="opacity-78"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
{currentAgent.name}
</span>
<ChevronDown
size={12}
className={cn(
'shrink-0 text-muted-foreground/60 transition-transform',
open && 'rotate-180',
)}
/>
</button>
</DropdownTrigger>
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow
key={agent.id}
agent={agent}
isActive={currentAgentId === agent.id}
onClick={() => handleSelect(agent.id)}
/>
))}
{enabledExternalAgents.length > 0 && (
<>
<div className="mx-0 my-1 border-t border-border/50" />
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
{enabledExternalAgents.map((agent) => (
<AgentMenuRow
key={agent.id}
agent={agent}
isActive={currentAgentId === agent.id}
subtitle={agent.command}
onClick={() => handleSelect(agent.id)}
/>
))}
</>
)}
{unconfiguredDiscovered.length > 0 && (
<>
<div className="mx-0 my-1 border-t border-border/50" />
<SectionLabel
action={
onRediscover && (
<button
onClick={onRediscover}
disabled={isDiscovering}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
title={t('ai.chat.rescan')}
>
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
)
}
>
{t('ai.chat.detectedOnMachine')}
</SectionLabel>
{unconfiguredDiscovered.map((agent) => (
<DiscoveredAgentRow
key={agent.command}
agent={agent}
onEnable={() => handleEnableDiscovered(agent)}
/>
))}
</>
)}
<div className="mx-0 my-1 border-t border-border/50" />
<button
onClick={handleManageAgents}
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
>
<Settings size={16} className="opacity-72 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
</button>
</DropdownContent>
</Dropdown>
);
};
export default React.memo(AgentSelector);

562
components/ai/ChatInput.tsx Normal file
View File

@@ -0,0 +1,562 @@
/**
* ChatInput - Zed-style bottom input area for the AI chat panel
*
* Thin wrapper around the AI Elements prompt-input components.
* Bordered textarea with monospace placeholder, expand toggle,
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
providerName?: string;
modelName?: string;
agentName?: string;
placeholder?: string;
/** Available model presets for the current agent */
modelPresets?: AgentModelPreset[];
/** Currently selected model ID */
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached files (images, PDFs, etc.) */
files?: UploadedFile[];
/** Callback to add files (paste/drop) */
onAddFiles?: (files: File[]) => void;
/** Callback to remove a file */
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
onPermissionModeChange?: (mode: AIPermissionMode) => void;
}
const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
onStop,
isStreaming = false,
disabled = false,
providerName,
modelName,
agentName,
placeholder,
modelPresets = [],
selectedModelId,
onModelSelect,
files = [],
onAddFiles,
onRemoveFile,
hosts = [],
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
// Detect if user just typed @
if (
hosts.length > 0 &&
newValue.length > value.length &&
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
}
}, [onChange, value, hosts.length, showAtMention]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
const name = host.label || host.hostname;
const lastAt = value.lastIndexOf('@');
const newValue = lastAt >= 0
? value.slice(0, lastAt) + `@${name} `
: value + `@${name} `;
onChange(newValue);
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
}
}, [onAddFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
onAddFiles?.(droppedFiles);
}
}, [onAddFiles]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
: t('ai.chat.placeholderDefault');
const handleSubmit = useCallback(
(_text: string, _event: FormEvent<HTMLFormElement>) => {
onSend();
},
[onSend],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
// Permission mode chip removed — agents run in autonomous mode
// selectedModelId may be "model/thinking" for codex
const selectedBaseModelId = selectedModelId?.split('/')[0];
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{files.map((file) => (
<div
key={file.id}
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
>
{file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[80px]">{file.filename}</span>
<button
type="button"
onClick={() => onRemoveFile?.(file.id)}
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
>
<X size={8} />
</button>
</div>
))}
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddFiles?.(Array.from(e.target.files));
e.target.value = '';
}
}}
/>
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled || isStreaming}
className={expanded ? 'max-h-[220px]' : undefined}
/>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
title={expanded ? 'Collapse' : 'Expand'}
>
<Expand size={12} />
</button>
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
</>,
document.body,
)}
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 flex-wrap">
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
title="Attach"
aria-label="Attach file"
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="menu"
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<FileText size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
>
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</>,
document.body,
)}
<button
ref={modelBtnRef}
type="button"
onClick={() => {
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('model');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
aria-label="Select model"
aria-expanded={showModelPicker}
>
<Cpu size={11} className="text-muted-foreground/64" />
<span className="truncate max-w-[82px]">{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
const isSelected = preset.id === selectedBaseModelId;
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
return (
<div
key={preset.id}
className="relative"
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}
onClick={() => {
if (!hasThinking) {
onModelSelect?.(preset.id);
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
<div role="listbox" aria-label="Thinking level" className="absolute left-full top-0 ml-1 min-w-[120px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{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 px-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>
</>,
document.body,
)}
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
title={t('ai.safety.permissionMode')}
aria-label="Permission mode"
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Permission mode"
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{([
{ mode: 'autonomous' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
]).map(({ mode, icon: Icon, color, label, desc }) => (
<button
key={mode}
type="button"
role="option"
aria-selected={permissionMode === mode}
onClick={() => {
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"
>
{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>
</button>
))}
</div>
</>,
document.body,
)}
</>
)}
</PromptInputTools>
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1">
<PromptInputSubmit
status={status}
onStop={onStop}
disabled={!value.trim() || disabled}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>
);
};
export default React.memo(ChatInput);

View File

@@ -0,0 +1,224 @@
/**
* ChatMessageList - Renders the list of chat messages
*
* Claude-Code-style: user messages in bordered bubbles (right-aligned),
* assistant responses as plain text (left-aligned, no border/bg).
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, FileText } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '../ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import { InlineApprovalCard } from './InlineApprovalCard';
import ThinkingBlock from './ThinkingBlock';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onApprove?: (messageId: string) => void;
onReject?: (messageId: string) => void;
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const resolvedToolCallIds = new Set(
visibleMessages
.filter((m) => m.role === 'tool')
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build a map from toolCallId → toolName for display
const toolCallNames = new Map<string, string>();
for (const m of visibleMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
}
}
}
if (visibleMessages.length === 0 && !isStreaming) {
return (
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-[13px] text-muted-foreground/40 text-center">
{t('ai.chat.emptyHint')}
</p>
</div>
);
}
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
return (
<Conversation className="flex-1">
<ConversationContent className="gap-1.5 px-4 py-2">
{visibleMessages.map((message) => {
if (message.role === 'tool') {
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
result={tr.content}
isError={tr.isError}
/>
))}
</React.Fragment>
);
}
const isUser = message.role === 'user';
const isLastAssistant = message === lastAssistantMessage;
const isThisStreaming = isStreaming && isLastAssistant;
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{/* Thinking block */}
{!isUser && message.thinking && (
<ThinkingBlock
content={message.thinking}
isStreaming={!!isThisStreaming && !message.content}
durationMs={message.thinkingDurationMs}
/>
)}
{/* User attachments (images, files) — fallback to legacy `images` field */}
{isUser && (message.attachments ?? message.images)?.length && (
<div className="flex gap-1.5 flex-wrap mb-1">
{(message.attachments ?? message.images)!.map((att, i) => (
att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
src={`data:${att.mediaType};base64,${att.base64Data}`}
alt={att.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
/>
) : (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
</div>
)
))}
</div>
)}
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running'}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
/>
))}
{/* Inline approval card */}
{message.pendingApproval && (
<InlineApprovalCard
toolName={message.pendingApproval.toolName}
toolArgs={message.pendingApproval.toolArgs}
status={message.pendingApproval.status}
onApprove={() => onApprove?.(message.id)}
onReject={() => onReject?.(message.id)}
/>
)}
{/* Status text with shimmer */}
{message.statusText && (
<div className="py-1">
<span className="thinking-shimmer text-xs">{message.statusText}</span>
</div>
)}
{/* Error info */}
{message.errorInfo && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{message.errorInfo.message}
</p>
{message.errorInfo.retryable && (
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
)}
</div>
</div>
)}
</MessageContent>
</Message>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
<div className="flex items-center gap-1 py-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:300ms]" />
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
};
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
if (prev.isStreaming !== next.isStreaming) return false;
if (prev.onApprove !== next.onApprove) return false;
if (prev.onReject !== next.onReject) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
// Shallow-compare each message by reference
for (let i = 0; i < prev.messages.length; i++) {
if (prev.messages[i] !== next.messages[i]) {
// For the last message during streaming, compare by content to avoid
// re-renders when only the array reference changed but content is the same
const p = prev.messages[i];
const n = next.messages[i];
if (
p.id !== n.id ||
p.content !== n.content ||
p.thinking !== n.thinking ||
p.role !== n.role ||
p.statusText !== n.statusText ||
p.executionStatus !== n.executionStatus ||
p.pendingApproval !== n.pendingApproval ||
p.errorInfo !== n.errorInfo ||
p.toolCalls !== n.toolCalls ||
p.toolResults !== n.toolResults
) {
return false;
}
}
}
return true;
}
export default React.memo(ChatMessageList, areMessagesEqual);

View File

@@ -0,0 +1,82 @@
/**
* ConversationExport - Dropdown button for exporting chat sessions
*
* Small download icon button with a dropdown offering Markdown, JSON,
* and Plain Text export formats.
*/
import { Download, FileJson, FileText, FileType } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AISession } from '../../infrastructure/ai/types';
import { Button } from '../ui/button';
import {
Dropdown,
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
interface ConversationExportProps {
session: AISession | null;
onExport: (format: 'md' | 'json' | 'txt') => void;
className?: string;
}
const EXPORT_OPTIONS = [
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
];
const ConversationExport: React.FC<ConversationExportProps> = ({
session,
onExport,
className,
}) => {
const { t } = useI18n();
const handleExport = useCallback(
(format: 'md' | 'json' | 'txt') => {
onExport(format);
},
[onExport],
);
const hasMessages = session && session.messages.length > 0;
return (
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
<Download size={14} />
</Button>
</DropdownTrigger>
<DropdownContent
align="end"
sideOffset={6}
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
{t('ai.chat.exportAs')}
</div>
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
<button
key={format}
onClick={() => handleExport(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
>
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
<span>{t(labelKey)}</span>
</button>
))}
</DropdownContent>
</Dropdown>
);
};
export default React.memo(ConversationExport);

View File

@@ -0,0 +1,169 @@
/**
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
*
* Shows a numbered list of steps with status indicators, host badges,
* optional command previews, and action buttons.
*/
import {
CheckCircle2,
Circle,
Loader2,
SkipForward,
XCircle,
} from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface ExecutionPlanStep {
description: string;
host?: string;
command?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
}
interface ExecutionPlanProps {
steps: ExecutionPlanStep[];
onApprove: () => void;
onModify: () => void;
onReject: () => void;
isExecuting: boolean;
}
// -------------------------------------------------------------------
// Status icon mapping
// -------------------------------------------------------------------
function StepStatusIcon({
status,
}: {
status: ExecutionPlanStep['status'];
}) {
switch (status) {
case 'pending':
return <Circle size={16} className="text-muted-foreground" />;
case 'running':
return (
<Loader2 size={16} className="text-blue-500 animate-spin" />
);
case 'completed':
return <CheckCircle2 size={16} className="text-green-500" />;
case 'failed':
return <XCircle size={16} className="text-destructive" />;
case 'skipped':
return (
<SkipForward size={16} className="text-muted-foreground/60" />
);
}
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
steps,
onApprove,
onModify,
onReject,
isExecuting,
}) => {
return (
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
<span className="text-sm font-medium">
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
</span>
</div>
{/* Steps list */}
<div className="divide-y divide-border/30">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'completed' && 'bg-green-500/5',
step.status === 'failed' && 'bg-destructive/5',
step.status === 'skipped' && 'opacity-50',
)}
>
{/* Step number + status icon */}
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
{index + 1}
</span>
<StepStatusIcon status={step.status} />
</div>
{/* Step content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
'text-sm',
step.status === 'skipped' && 'line-through',
)}
>
{step.description}
</span>
{step.host && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{step.host}
</Badge>
)}
</div>
{step.command && (
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
{step.command}
</code>
)}
</div>
</div>
))}
</div>
{/* Action buttons */}
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
{isExecuting ? (
<Button
variant="destructive"
size="sm"
onClick={onReject}
>
Cancel
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onReject}>
Cancel
</Button>
<Button variant="outline" size="sm" onClick={onModify}>
Modify Plan
</Button>
<Button size="sm" onClick={onApprove}>
Approve
</Button>
</>
)}
</div>
</div>
);
};
ExecutionPlan.displayName = 'ExecutionPlan';
export default ExecutionPlan;
export { ExecutionPlan };
export type { ExecutionPlanProps, ExecutionPlanStep };

View File

@@ -0,0 +1,193 @@
/**
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
*
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
*/
import { Check, ShieldAlert, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
interface InlineApprovalCardProps {
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
onApprove: () => void;
onReject: () => void;
}
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
toolName,
toolArgs,
status,
onApprove,
onReject,
}) => {
const { t } = useI18n();
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const isPending = status === 'pending';
const [responded, setResponded] = useState(false);
// Use refs to always access the latest callbacks without re-registering the listener
const onApproveRef = useRef(onApprove);
const onRejectRef = useRef(onReject);
onApproveRef.current = onApprove;
onRejectRef.current = onReject;
const isDisabled = !isPending || responded;
const handleApprove = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onApproveRef.current();
}, [isDisabled]);
const handleReject = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onRejectRef.current();
}, [isDisabled]);
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isDisabled) return;
if (e.key === 'Enter') {
e.preventDefault();
handleApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
handleReject();
}
}, [isDisabled, handleApprove, handleReject]);
// Auto-focus approve button and auto-scroll into view when mounted as pending
useEffect(() => {
if (isPending && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
approveBtnRef.current?.focus();
}
}, [isPending]);
let formattedArgs: string;
try {
formattedArgs = JSON.stringify(toolArgs, null, 2);
} catch {
formattedArgs = String(toolArgs);
}
// Extract target session info if present
const sessionId = toolArgs?.sessionId as string | undefined;
return (
<div
ref={cardRef}
tabIndex={0}
role="alertdialog"
aria-label="Tool execution approval required"
onKeyDown={handleKeyDown}
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
isPending
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: status === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: 'border-red-500/20 bg-red-500/[0.03]'
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5">
<ShieldAlert
size={13}
className={
isPending
? 'text-yellow-500/70 shrink-0'
: status === 'approved'
? 'text-green-400/70 shrink-0'
: 'text-red-400/70 shrink-0'
}
/>
<span className="text-[11px] font-medium text-foreground/70">
{t('ai.chat.toolApprovalTitle')}
</span>
{!isPending && (
<Badge
className={`ml-auto text-[10px] px-1.5 py-0 ${
status === 'approved'
? 'bg-green-600/20 text-green-400 border-green-600/30'
: 'bg-red-600/20 text-red-400 border-red-600/30'
}`}
>
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
</Badge>
)}
</div>
{/* Tool info */}
<div className="px-3 pb-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
{toolName}
</code>
</div>
{sessionId && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
{sessionId}
</code>
</div>
)}
{/* Arguments */}
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
{formattedArgs}
</pre>
</div>
{/* Actions or hint */}
{isPending && (
<div className="flex items-center justify-between pt-0.5">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={responded}
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
disabled={responded}
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
InlineApprovalCard.displayName = 'InlineApprovalCard';
export default InlineApprovalCard;
export { InlineApprovalCard };
export type { InlineApprovalCardProps };

View File

@@ -0,0 +1,200 @@
/**
* PermissionDialog - Modal for AI agent tool call permission requests.
*
* Shown when the agent needs user approval to execute a tool call.
* Displays tool name, arguments, recommendation, and approve/reject actions.
*/
import { ShieldAlert } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface PermissionDialogProps {
open: boolean;
toolCall: { name: string; arguments: Record<string, unknown> } | null;
recommendation: 'allow' | 'confirm' | 'deny';
onApprove: () => void;
onReject: () => void;
onDismiss: () => void;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const PermissionDialog: React.FC<PermissionDialogProps> = ({
open,
toolCall,
recommendation,
onApprove,
onReject,
onDismiss,
}) => {
const { t } = useI18n();
const isDenied = recommendation === 'deny';
// Keyboard shortcuts: Enter to approve, Escape to reject
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isDenied) {
e.preventDefault();
onApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
onReject();
}
},
[isDenied, onApprove, onReject],
);
// Format arguments as readable code block content
let formattedArgs = '';
if (toolCall) {
try {
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
} catch {
formattedArgs = String(toolCall.arguments);
}
}
// Extract host/session info from arguments if present
const sessionId =
toolCall?.arguments?.sessionId as string | undefined;
const sessionIds =
toolCall?.arguments?.sessionIds as string[] | undefined;
const recommendationBadge = () => {
switch (recommendation) {
case 'allow':
return (
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.recommendAllow')}
</Badge>
);
case 'confirm':
return (
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
{t('ai.chat.recommendConfirm')}
</Badge>
);
case 'deny':
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert
size={20}
className={cn(
isDenied ? 'text-destructive' : 'text-yellow-500',
)}
/>
{t('ai.chat.permissionRequired')}
</DialogTitle>
<DialogDescription>
{t('ai.chat.permissionDescription')}
</DialogDescription>
</DialogHeader>
{toolCall && (
<div className="space-y-3">
{/* Tool name and recommendation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
{toolCall.name}
</code>
</div>
{recommendationBadge()}
</div>
{/* Target session(s) */}
{(sessionId || sessionIds) && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
{sessionId && (
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{sessionId}
</code>
)}
{sessionIds && (
<div className="flex flex-wrap gap-1">
{sessionIds.map((id) => (
<code
key={id}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{id}
</code>
))}
</div>
)}
</div>
)}
{/* Arguments code block */}
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{formattedArgs}
</pre>
</div>
{/* Deny warning */}
{isDenied && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('ai.chat.commandBlocked')}
</p>
</div>
)}
</div>
)}
<DialogFooter>
{isDenied ? (
<Button variant="destructive" onClick={onReject} className="w-full">
{t('ai.chat.reject')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={onReject}
className="border-destructive/30 text-destructive hover:bg-destructive/10"
>
{t('ai.chat.reject')}
</Button>
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
PermissionDialog.displayName = 'PermissionDialog';
export default PermissionDialog;
export { PermissionDialog };
export type { PermissionDialogProps };

View File

@@ -0,0 +1,138 @@
/**
* ThinkingBlock - Collapsible thinking/reasoning display
*
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
* - When done: auto-collapses to "Thought for Xs", click to expand
* - Content area has max-height with scroll and top gradient fade
*/
import { ChevronRight } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
interface ThinkingBlockProps {
content: string;
isStreaming: boolean;
durationMs?: number;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}m ${remaining}s`;
}
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
content,
isStreaming,
durationMs,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = useState(isStreaming);
const [elapsed, setElapsed] = useState(0);
const wasStreamingRef = useRef(false);
const startRef = useRef(Date.now());
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-collapse when streaming ends
useEffect(() => {
if (wasStreamingRef.current && !isStreaming) {
setIsExpanded(false);
}
wasStreamingRef.current = isStreaming;
}, [isStreaming]);
// Expand when streaming starts
useEffect(() => {
if (isStreaming) {
setIsExpanded(true);
startRef.current = Date.now();
}
}, [isStreaming]);
// Elapsed time ticker
useEffect(() => {
if (!isStreaming) return;
const timer = setInterval(() => {
setElapsed(Date.now() - startRef.current);
}, 1000);
return () => clearInterval(timer);
}, [isStreaming]);
// Auto-scroll to bottom while streaming
useEffect(() => {
if (isStreaming && isExpanded && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [content, isStreaming, isExpanded]);
const toggle = useCallback(() => setIsExpanded(e => !e), []);
const displayDuration = durationMs || elapsed;
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
return (
<div className="mb-0.5">
{/* Header */}
<button
onClick={toggle}
aria-expanded={isExpanded}
aria-controls="thinking-block-content"
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
isExpanded && 'rotate-90',
!isExpanded && 'opacity-50',
)}
/>
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
{isStreaming ? (
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
) : (
displayDuration > 0
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
: t('ai.chat.thought')
)}
</span>
{isStreaming && elapsed > 0 && (
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
{formatDuration(elapsed)}
</span>
)}
{!isExpanded && !isStreaming && preview && (
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
{preview}
</span>
)}
</button>
{/* Content */}
{isExpanded && content && (
<div id="thinking-block-content" className="relative">
{/* Top gradient fade */}
{isStreaming && (
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
)}
<div
ref={scrollRef}
className={cn(
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
)}
>
{content}
</div>
</div>
)}
</div>
);
};
export default React.memo(ThinkingBlock);

View File

@@ -0,0 +1,863 @@
/**
* useAIChatStreaming — Encapsulates all streaming logic for the AI chat panel.
*
* Handles:
* - Catty agent streaming via Vercel AI SDK `streamText`
* - External agent streaming (ACP and raw process)
* - Text-delta batching via requestAnimationFrame
* - Abort controller management
* - Stream state tracking (per-session)
* - Error reporting
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
// -------------------------------------------------------------------
/** Shape of a text/text-delta chunk from the Vercel AI SDK fullStream. */
interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
}
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
}
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
interface ToolCallChunk {
type: 'tool-call';
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
interface ToolResultChunk {
type: 'tool-result';
toolCallId: string;
output?: unknown;
result?: unknown;
}
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
interface ToolApprovalRequestChunk {
type: 'tool-approval-request';
approvalId: string;
toolCall: {
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
input?: Record<string, unknown>;
};
}
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
interface ErrorChunk {
type: 'error';
error: unknown;
}
/** Union of all stream chunk shapes we handle. */
type StreamChunk =
| TextDeltaChunk
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolApprovalRequestChunk
| ErrorChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
/** Terminal session info used throughout the streaming hooks. */
export interface TerminalSessionInfo {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).netcatty as PanelBridge | undefined;
}
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
export interface ApprovalInfo {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
}
/** Pending approval context stored between approval request and user response. */
export interface PendingApprovalContext {
sessionId: string;
scopeKey: string;
sdkMessages: Array<ModelMessage>;
approvalInfo: ApprovalInfo;
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
getExecutorContext: () => ExecutorContext;
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseAIChatStreamingParams {
maxIterations: number;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseAIChatStreamingReturn {
/** Set of session IDs currently streaming. */
streamingSessionIds: Set<string>;
/** Set or unset streaming state for a session. */
setStreamingForScope: (key: string, val: boolean) => void;
/** Ref to per-session abort controllers. */
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
/** Process a Catty agent stream, returning approval info if one is requested. */
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
sendToExternalAgent: (
sessionId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
context: SendToExternalContext,
) => Promise<void>;
/** Report a streaming error to the chat. */
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
}
/** Context values needed by sendToCattyAgent that change frequently (avoids stale closures). */
export interface SendToCattyContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
autoTitleSession: (sessionId: string, text: string) => void;
}
/** Context values needed by sendToExternalAgent that change frequently. */
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
providers: ProviderConfig[];
selectedAgentModel?: string;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
// Per-session streaming state (keyed by sessionId)
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
// Per-scope abort controllers
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
// -------------------------------------------------------------------
// reportStreamError
// -------------------------------------------------------------------
const reportStreamError = useCallback((
sessionId: string,
abortSignal: AbortSignal,
err: unknown,
) => {
if (abortSignal.aborted) return;
let errorStr: string;
if (err instanceof Error) errorStr = err.message;
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
else if (typeof err === 'string') errorStr = err;
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo,
timestamp: Date.now(),
});
}, [updateLastMessage, addMessageToSession]);
// -------------------------------------------------------------------
// processCattyStream
// -------------------------------------------------------------------
const processCattyStream = useCallback(async (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
const result = streamText({
model,
messages: sdkMessages,
system: systemPrompt,
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
});
// Track the current assistant message ID so updates target the correct message
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
const reader = result.fullStream.getReader();
let pendingApprovalInfo: ApprovalInfo | null = null;
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
let rafId: number | null = null;
const flushText = () => {
if (pendingText) {
const text = pendingText;
pendingText = '';
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: text,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
content: msg.content + text,
}));
}
}
rafId = null;
};
const cancelPendingFlush = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Use the StreamChunk union for type narrowing instead of unsafe casts
const chunk = value as StreamChunk;
switch (chunk.type) {
case 'text':
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
if (text) {
pendingText += text;
if (rafId === null) {
rafId = requestAnimationFrame(flushText);
}
}
break;
}
case 'reasoning':
case 'reasoning-start':
case 'reasoning-delta': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text;
if (rText) {
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
thinking: rText,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
thinking: (msg.thinking || '') + rText,
}));
}
}
break;
}
case 'reasoning-end':
case 'text-start':
case 'text-end':
case 'start':
case 'finish':
case 'start-step':
case 'finish-step':
break;
case 'tool-call': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
name: typedChunk.toolName,
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
}],
executionStatus: 'running',
statusText: undefined,
}));
break;
}
case 'tool-result': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolResultChunk;
// Mark the assistant message's tool execution as completed
updateMessageById(streamSessionId, activeMsgId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
const toolOutput = typedChunk.output ?? typedChunk.result;
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'tool',
content: '',
toolResults: [{
toolCallId: typedChunk.toolCallId,
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
}],
timestamp: Date.now(),
executionStatus: 'completed',
});
lastAddedRole = 'tool';
break;
}
case 'tool-approval-request': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolApprovalRequestChunk;
pendingApprovalInfo = {
approvalId: typedChunk.approvalId,
toolCallId: typedChunk.toolCall.toolCallId,
toolName: typedChunk.toolCall.toolName,
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
};
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
pendingApproval: {
...pendingApprovalInfo!,
status: 'pending' as const,
},
}));
break;
}
case 'error': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ErrorChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(
typedChunk.error instanceof Error ? typedChunk.error.message
: typeof typedChunk.error === 'string' ? typedChunk.error
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
),
timestamp: Date.now(),
});
break;
}
default:
break;
}
}
} finally {
cancelPendingFlush();
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
// sendToExternalAgent
// -------------------------------------------------------------------
const sendToExternalAgent = useCallback(async (
sessionId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
// Push terminal session metadata to MCP bridge
if (bridge?.aiMcpUpdateSessions) {
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
const agentProviderId = openaiProvider?.id;
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
if (needsNewAssistantMsg) {
needsNewAssistantMsg = false;
addMessageToSession(sessionId, {
id: generateId(), role: 'assistant', content: '', timestamp: Date.now(),
model: agentConfig.name || 'external',
});
}
};
await runAcpAgentTurn(
bridge,
requestId,
sessionId,
agentConfig,
trimmed,
{
onTextDelta: (text: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
content: msg.content + text,
statusText: undefined,
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
}));
},
onThinkingDelta: (text: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg, thinking: (msg.thinking || '') + text,
}));
},
onThinkingDone: () => {
updateLastMessage(sessionId, msg => ({
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
},
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
},
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
updateLastMessage(sessionId, msg => {
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
// Only patch tool call name if the existing name is missing/generic
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
: msg.toolCalls;
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
});
addMessageToSession(sessionId, {
id: generateId(), role: 'tool', content: '',
toolResults: [{ toolCallId, content: result, isError: false }],
timestamp: Date.now(), executionStatus: 'completed',
});
needsNewAssistantMsg = true;
},
onStatus: (message: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
},
onSessionId: (externalSessionId: string) => {
context.updateExternalSessionId?.(sessionId, externalSessionId);
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
},
onDone: () => {},
},
abortController.signal,
agentProviderId,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
},
onDone: () => {},
},
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
abortController.signal,
);
}
}, [
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
]);
// -------------------------------------------------------------------
// sendToCattyAgent
// -------------------------------------------------------------------
const sendToCattyAgent = useCallback(async (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
}));
const tools = createCattyTools(
bridge,
getExecutorContext,
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
hosts: context.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
});
// Guard: activeProvider must exist for Catty agent path
if (!context.activeProvider) {
reportStreamError(sessionId, abortController.signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
return;
}
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig({
...context.activeProvider,
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
for (const m of allMessages) {
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
if (messageAttachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: m.content });
for (const att of messageAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
}
for (const tc of resolvedCalls) {
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: findToolName(tr.toolCallId),
output: { type: 'text' as const, value: tr.content },
})),
});
}
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: trimmed });
for (const att of attachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
if (approvalInfo) {
context.setPendingApproval({
sessionId,
scopeKey: sendScopeKey,
sdkMessages,
approvalInfo,
model,
systemPrompt,
tools,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
getExecutorContext,
});
return; // Keep streaming flag — waiting for user approval
}
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);
} finally {
// Clear any lingering statusText when the stream finishes
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
context.autoTitleSession(sessionId, trimmed);
}
}, [
processCattyStream, reportStreamError, setStreamingForScope,
updateLastMessage,
]);
return {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
};
}

View File

@@ -0,0 +1,76 @@
/**
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
*
* Handles:
* - Export in markdown, JSON, and plain text formats
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
*/
import React, { useCallback, useEffect, useRef } from 'react';
import type { AISession } from '../../../infrastructure/ai/types';
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseConversationExportReturn {
/** Trigger a download of the active session in the given format. */
handleExport: (format: 'md' | 'json' | 'txt') => void;
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useConversationExport(
activeSession: AISession | null,
): UseConversationExportReturn {
// Ref to track active object URLs for cleanup on unmount (Issue #19)
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
// Clean up object URLs on unmount
useEffect(() => {
const urls = activeObjectUrlsRef.current;
return () => {
urls.forEach(url => URL.revokeObjectURL(url));
urls.clear();
};
}, []);
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
if (!activeSession) return;
let content: string;
switch (format) {
case 'md': content = exportAsMarkdown(activeSession); break;
case 'json': content = exportAsJSON(activeSession); break;
case 'txt': content = exportAsPlainText(activeSession); break;
}
const filename = getExportFilename(activeSession, format);
// Create a download blob
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
// Track URL for cleanup on unmount (Issue #19)
activeObjectUrlsRef.current.add(url);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a generous delay to ensure download completes, then remove from tracking set
const revokeTimeout = setTimeout(() => {
URL.revokeObjectURL(url);
activeObjectUrlsRef.current.delete(url);
}, 60_000); // 60 seconds to be safe for large files
// If component unmounts before timeout, cleanup effect will revoke it
void revokeTimeout; // suppress unused warning
}, [activeSession]);
return {
handleExport,
activeObjectUrlsRef,
};
}

View File

@@ -0,0 +1,298 @@
/**
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
*
* Handles:
* - Pending approval context management
* - Approval timeout (auto-clear after 5 minutes)
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
* - Resuming the Catty stream after approval
*/
import React, { useCallback, useRef } from 'react';
import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
ChatMessage,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import type {
ApprovalInfo,
PendingApprovalContext,
} from './useAIChatStreaming';
import { getNetcattyBridge } from './useAIChatStreaming';
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseToolApprovalParams {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
t: (key: string) => string;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseToolApprovalReturn {
/** Ref to the current pending approval context (null when none). */
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
/** Set or clear the pending approval context (manages timeout). */
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
/** Handle a user's approve/reject response from InlineApprovalCard. */
handleApprovalResponse: (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => Promise<void>;
}
/** Context values needed by handleApprovalResponse that change frequently. */
export interface ToolApprovalContext {
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
}: UseToolApprovalParams): UseToolApprovalReturn {
// Pending approval context — stores SDK state needed to resume after user approves/rejects
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(sharedPendingApprovalContext);
pendingApprovalContextRef.current = sharedPendingApprovalContext;
/** Set pending approval context with a 5-minute auto-clear timeout. */
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
// Clear any existing timeout
if (sharedPendingApprovalTimeout) {
clearTimeout(sharedPendingApprovalTimeout);
sharedPendingApprovalTimeout = null;
}
sharedPendingApprovalContext = ctx;
pendingApprovalContextRef.current = ctx;
if (ctx) {
sharedPendingApprovalTimeout = setTimeout(() => {
// Auto-clear after 5 minutes if user never responds
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
sharedPendingApprovalContext = null;
pendingApprovalContextRef.current = null;
setStreamingForScope(ctx.sessionId, false);
abortControllersRef.current.get(ctx.sessionId)?.abort();
abortControllersRef.current.delete(ctx.sessionId);
// Notify the user that the approval timed out
updateLastMessage(ctx.sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(ctx.sessionId, {
id: generateId(),
role: 'assistant',
content: t('ai.chat.approvalTimeout'),
timestamp: Date.now(),
});
}
sharedPendingApprovalTimeout = null;
}, 5 * 60 * 1000); // 5 minutes
}
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
// Handle inline approval response (approve/reject from InlineApprovalCard)
const handleApprovalResponse = useCallback(async (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => {
const ctx = pendingApprovalContextRef.current;
if (!ctx) return;
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
const {
sessionId: sid,
scopeKey: sk,
sdkMessages,
approvalInfo,
model: ctxModel,
scopeType,
scopeLabel,
getExecutorContext,
} = ctx;
// Clear pending approval (and its timeout) via setPendingApproval
setPendingApproval(null);
// Update the message's pendingApproval status using message ID
updateMessageById(sid, messageId, msg => ({
...msg,
pendingApproval: msg.pendingApproval
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
: undefined,
}));
if (!approved) {
// User rejected — add denial text and stop
updateMessageById(sid, messageId, msg => ({
...msg,
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
statusText: '',
executionStatus: 'completed',
}));
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
return;
}
// User approved — construct SDK messages with approval response and resume
const resumeMessages: Array<Record<string, unknown>> = [
...sdkMessages,
// The assistant message that contained the tool call + approval request
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: approvalInfo.toolCallId,
toolName: approvalInfo.toolName,
input: approvalInfo.toolArgs,
},
{
type: 'tool-approval-request',
approvalId: approvalInfo.approvalId,
toolCallId: approvalInfo.toolCallId,
},
],
},
// The user's approval response
{
role: 'tool',
content: [
{
type: 'tool-approval-response',
approvalId: approvalInfo.approvalId,
approved: true,
},
],
},
];
// Create a new assistant message placeholder for the continuation
const newAssistantMsgId = generateId();
addMessageToSession(sid, {
id: newAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
const abortController = new AbortController();
abortControllersRef.current.set(sid, abortController);
try {
// Rebuild tools and system prompt with the latest permission mode to prevent
// stale settings, while keeping the original AI scope pinned to its workspace/session.
const bridge = getNetcattyBridge();
const freshExecutorContext = getExecutorContext();
const freshTools = createCattyTools(
bridge,
getExecutorContext,
approvalContext.commandBlocklist,
approvalContext.globalPermissionMode,
approvalContext.webSearchConfig ?? undefined,
);
const freshSystemPrompt = buildSystemPrompt({
scopeType,
scopeLabel,
hosts: freshExecutorContext.sessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
});
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
if (newApprovalInfo) {
// Another approval needed — save context for the next round (with timeout)
setPendingApproval({
sessionId: sid,
scopeKey: sk,
sdkMessages: resumeMessages,
approvalInfo: newApprovalInfo,
model: ctxModel,
systemPrompt: freshSystemPrompt,
tools: freshTools,
scopeType,
scopeLabel,
getExecutorContext,
});
return;
}
} catch (err) {
console.error('[Catty resume] streamText error:', err);
if (!abortController.signal.aborted) {
const errorStr = err instanceof Error ? err.message : String(err);
updateMessageById(sid, newAssistantMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sid, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(errorStr),
timestamp: Date.now(),
});
}
} finally {
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
// Clear any lingering statusText when the resumed stream finishes
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
}
}
}, [
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, abortControllersRef, t, setPendingApproval,
]);
return {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
};
}

View File

@@ -34,13 +34,21 @@ export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) =
interface SelectProps {
value: string;
options: { value: string; label: string }[];
options: { value: string; label: string; icon?: React.ReactNode }[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
placeholder?: string;
}
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => {
export const Select: React.FC<SelectProps> = ({
value,
options,
onChange,
className,
disabled,
placeholder,
}) => {
const selectedOption = options.find((opt) => opt.value === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
@@ -50,7 +58,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
className,
)}
>
<SelectPrimitive.Value>{selectedOption?.label ?? value}</SelectPrimitive.Value>
<SelectPrimitive.Value placeholder={placeholder}>
<span className="flex items-center gap-2">
{selectedOption?.icon}
{selectedOption?.label}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
@@ -76,7 +89,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemText>
<span className="flex items-center gap-2">
{opt.icon}
{opt.label}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
@@ -120,4 +138,3 @@ export const SettingsTabContent: React.FC<{
</ScrollArea>
</TabsContent>
);

View File

@@ -0,0 +1,540 @@
/**
* Settings AI Tab - AI provider configuration, agent CLI detection, and safety settings
*
* Sub-components live in ./ai/ directory:
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
* - ModelSelector, ProviderIconBadge
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SettingsAITabProps {
providers: ProviderConfig[];
addProvider: (provider: ProviderConfig) => void;
updateProvider: (id: string, updates: Partial<ProviderConfig>) => void;
removeProvider: (id: string) => void;
activeProviderId: string;
setActiveProviderId: (id: string) => void;
activeModelId: string;
setActiveModelId: (id: string) => void;
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
externalAgents: ExternalAgentConfig[];
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
defaultAgentId: string;
setDefaultAgentId: (id: string) => void;
commandBlocklist: string[];
setCommandBlocklist: (value: string[]) => void;
commandTimeout: number;
setCommandTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
const SettingsAITab: React.FC<SettingsAITabProps> = ({
providers,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId: _activeModelId,
setActiveModelId,
globalPermissionMode,
setGlobalPermissionMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
const [isCodexLoading, setIsCodexLoading] = useState(false);
const [codexError, setCodexError] = useState<string | null>(null);
// Path detection state
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
const [codexCustomPath, setCodexCustomPath] = useState("");
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
setResolving(true);
try {
const result = await bridge.aiResolveCli({
command: agentKey,
customPath: customPath.trim(),
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
}
} catch (err) {
console.error("Path resolution failed:", err);
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
// Add a new provider from preset
const handleAddProvider = useCallback(
(providerId: AIProviderId) => {
const preset = PROVIDER_PRESETS[providerId];
const id = `provider_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
addProvider({
id,
providerId,
name: preset.name,
baseURL: preset.defaultBaseURL,
enabled: false,
});
// Auto-open config form
setEditingProviderId(id);
},
[addProvider],
);
// Remove provider with confirmation
const handleRemoveProvider = useCallback(
(id: string) => {
const provider = providers.find((p) => p.id === id);
const name = provider?.name || id;
const ok = window.confirm(
t('confirm.removeProvider', { name }),
);
if (!ok) return;
removeProvider(id);
if (editingProviderId === id) {
setEditingProviderId(null);
}
},
[removeProvider, editingProviderId, providers, t],
);
// Agent options for default agent
const agentOptions = useMemo(() => [
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
...externalAgents
.filter((a) => a.enabled)
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasOpenAiProviderKey = providers.some(
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
);
const refreshCodexIntegration = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration();
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, []);
useEffect(() => {
void refreshCodexIntegration();
}, [refreshCodexIntegration]);
useEffect(() => {
if (!codexLoginSession || codexLoginSession.state !== "running") {
return;
}
const bridge = getBridge();
if (!bridge?.aiCodexGetLoginSession) {
return;
}
let cancelled = false;
const intervalId = window.setInterval(() => {
void bridge.aiCodexGetLoginSession?.(codexLoginSession.sessionId).then((result) => {
if (cancelled || !result?.ok || !result.session) return;
setCodexLoginSession(result.session);
if (result.session.state !== "running") {
if (result.session.state === "success") {
void refreshCodexIntegration();
}
}
}).catch((err) => {
if (!cancelled) {
setCodexError(normalizeCodexBridgeError(err));
}
});
}, 1000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [codexLoginSession, refreshCodexIntegration]);
const handleStartCodexLogin = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexStartLogin) return;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin();
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
}
setCodexLoginSession(result.session);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, []);
const handleCancelCodexLogin = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexCancelLogin || !codexLoginSession) return;
setCodexError(null);
try {
const result = await bridge.aiCodexCancelLogin(codexLoginSession.sessionId);
if (result.session) {
setCodexLoginSession(result.session);
}
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
}
}, [codexLoginSession]);
const handleOpenCodexLoginUrl = useCallback(() => {
const bridge = getBridge();
const url = codexLoginSession?.url;
if (!bridge?.openExternal || !url) return;
// Only allow https:// URLs to prevent opening arbitrary protocols
if (!url.startsWith("https://")) return;
void bridge.openExternal(url);
}, [codexLoginSession]);
const handleCodexLogout = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexLogout) return;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout();
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
}
setCodexLoginSession(null);
await refreshCodexIntegration();
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, [refreshCodexIntegration]);
return (
<TabsContent
value="ai"
className="data-[state=inactive]:hidden h-full flex flex-col"
>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{t('ai.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('ai.description')}
</p>
</div>
{/* -- Providers Section -- */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.providers')}</h3>
</div>
<AddProviderDropdown onAdd={handleAddProvider} />
</div>
{providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t('ai.providers.empty')}
</p>
</div>
) : (
<div className="space-y-2">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isActive={provider.id === activeProviderId}
onToggleEnabled={(enabled) => {
if (enabled) {
// Activate this provider, deactivate all others
setActiveProviderId(provider.id);
if (provider.defaultModel) {
setActiveModelId(provider.defaultModel);
}
for (const p of providers) {
if (p.id === provider.id) {
if (!p.enabled) updateProvider(p.id, { enabled: true });
} else {
if (p.enabled) updateProvider(p.id, { enabled: false });
}
}
} else {
// Deactivate this provider
if (activeProviderId === provider.id) {
setActiveProviderId("");
setActiveModelId("");
}
updateProvider(provider.id, { enabled: false });
}
}}
onEdit={() =>
setEditingProviderId(
editingProviderId === provider.id ? null : provider.id,
)
}
onRemove={() => handleRemoveProvider(provider.id)}
onUpdate={(updates) => {
updateProvider(provider.id, updates);
// If this is the active provider and model changed, update activeModelId
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
setActiveModelId(updates.defaultModel || "");
}
}}
isEditing={editingProviderId === provider.id}
onCancelEdit={() => setEditingProviderId(null)}
/>
))}
</div>
)}
</div>
{/* -- Codex Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="openai" size="sm" />
<h3 className="text-base font-medium">{t('ai.codex')}</h3>
</div>
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
onLogout={() => void handleCodexLogout()}
/>
</div>
{/* -- Claude Code Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="claude" size="sm" />
<h3 className="text-base font-medium">{t('ai.claude.title')}</h3>
</div>
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.defaultAgent')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<SettingRow
label={t('ai.defaultAgent')}
description={t('ai.defaultAgent.description')}
>
<Select
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
/>
</SettingRow>
</div>
</div>
)}
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
{/* -- Safety Section -- */}
<SafetySettings
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
commandBlocklist={commandBlocklist}
setCommandBlocklist={setCommandBlocklist}
commandTimeout={commandTimeout}
setCommandTimeout={setCommandTimeout}
maxIterations={maxIterations}
setMaxIterations={setMaxIterations}
/>
</div>
</div>
</TabsContent>
);
};
export default SettingsAITab;

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -253,6 +253,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Auto-open sidebar section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.desc')}
</p>
<button
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoOpenSidebar
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoOpenSidebar
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoOpenSidebar && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoOpenSidebar.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -5,6 +5,7 @@ import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload"
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
@@ -14,6 +15,7 @@ export default function SettingsSyncTab(props: {
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;
}) {
const {
vault,
@@ -21,6 +23,7 @@ export default function SettingsSyncTab(props: {
importDataFromString,
importPortForwardingRules,
clearVaultData,
onSettingsApplied,
} = props;
const onBuildPayload = useCallback((): SyncPayload => {
@@ -44,7 +47,10 @@ export default function SettingsSyncTab(props: {
}));
}
}
return buildSyncPayload(vault, effectiveRules);
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
@@ -52,9 +58,10 @@ export default function SettingsSyncTab(props: {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString, importPortForwardingRules],
[importDataFromString, importPortForwardingRules, onSettingsApplied],
);
const clearAllLocalData = useCallback(() => {

View File

@@ -55,6 +55,10 @@ interface SettingsSystemTabProps {
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
globalHotkeyEnabled: boolean;
setGlobalHotkeyEnabled: (enabled: boolean) => void;
autoUpdateEnabled: boolean;
setAutoUpdateEnabled: (enabled: boolean) => void;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
@@ -74,6 +78,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
autoUpdateEnabled,
setAutoUpdateEnabled,
updateState,
checkNow,
installUpdate,
@@ -367,6 +375,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
)}
</div>
</div>
<SettingRow
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
@@ -580,7 +597,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
className="w-44"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
@@ -599,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Toggle Window Hotkey */}
{/* Enable/Disable Global Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
label={t('settings.globalHotkey.enabled')}
description={t('settings.globalHotkey.enabledDesc')}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
<Toggle
checked={globalHotkeyEnabled}
onChange={setGlobalHotkeyEnabled}
/>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
)}
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
{/* Toggle Window Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
)}
</div>
{/* Close to Tray */}
<SettingRow

View File

@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
>
<Select
value={terminalSettings.osc52Clipboard ?? 'write-only'}
options={[
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
]}
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
@@ -616,7 +633,7 @@ export default function SettingsTerminalTab(props: {
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
]}
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
className="w-40"
className="w-48"
/>
</SettingRow>
</div>

View File

@@ -0,0 +1,55 @@
import React, { useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const AddProviderDropdown: React.FC<{
onAdd: (providerId: AIProviderId) => void;
}> = ({ onAdd }) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
return (
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="gap-1.5"
>
<Plus size={14} />
{t('ai.providers.add')}
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
</Button>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
{/* Menu */}
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
{providerIds.map((pid) => (
<button
key={pid}
onClick={() => {
onAdd(pid);
setIsOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
>
<ProviderIconBadge providerId={pid} size="sm" />
{PROVIDER_PRESETS[pid].name}
</button>
))}
</div>
</>
)}
</div>
);
};

View File

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

View File

@@ -0,0 +1,181 @@
import React from "react";
import { ExternalLink, LogIn, LogOut, RefreshCw, X } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo, CodexIntegrationStatus, CodexLoginSession } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CodexConnectionCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasOpenAiProviderKey: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
onCancel: () => void;
onOpenUrl: () => void;
onLogout: () => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
integration,
loginSession,
isLoading,
hasOpenAiProviderKey,
error,
onRefresh,
onConnect,
onCancel,
onOpenUrl,
onLogout,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
? t('ai.codex.notFound')
: loginSession?.state === "running"
? t('ai.codex.awaitingLogin')
: integration?.state === "connected_chatgpt"
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: !found
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
: loginSession?.output?.trim()
? loginSession.output.trim()
: integration?.rawOutput?.trim()
? integration.rawOutput.trim()
: "";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="openai" size="sm" />
<span className="text-sm font-medium">{t('ai.codex.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.codex.description')}
</p>
</div>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{status}
</div>
</div>
{/* Path detection info */}
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.codex.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.codex.check')}
</Button>
</div>
</div>
) : null}
{/* Connection & login UI -- only when codex is detected */}
{found && (
<>
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
{loginSession?.state === "running" ? (
<>
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
<ExternalLink size={14} className="mr-1.5" />
{t('ai.codex.openLogin')}
</Button>
<Button variant="outline" size="sm" onClick={onCancel}>
<X size={14} className="mr-1.5" />
{t('common.cancel')}
</Button>
</>
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout}>
<LogOut size={14} className="mr-1.5" />
{t('ai.codex.logout')}
</Button>
) : (
<Button variant="default" size="sm" onClick={onConnect}>
<LogIn size={14} className="mr-1.5" />
{t('ai.codex.connectChatGPT')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
{t('ai.codex.refreshStatus')}
</Button>
</div>
{hasOpenAiProviderKey && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
)}
</>
)}
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
{found && outputText && (
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
{outputText}
</pre>
)}
</div>
);
};

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
export const ModelSelector: React.FC<{
value: string;
onChange: (value: string) => void;
baseURL: string;
modelsEndpoint?: string;
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
skipTLSVerify?: boolean;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Ollama runs locally without auth; all other providers need an API key to list models
const needsApiKey = providerId !== "ollama";
const canFetch = !!modelsEndpoint && (!needsApiKey || !!apiKey);
const fetchModels = useCallback(async () => {
if (!modelsEndpoint) return;
const bridge = getFetchBridge();
if (!bridge?.aiFetch) return;
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
const headers: Record<string, string> = {};
if (apiKey) {
if (providerId === "anthropic") {
headers["x-api-key"] = apiKey;
headers["anthropic-version"] = "2023-06-01";
} else {
headers["Authorization"] = `Bearer ${apiKey}`;
}
}
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
}
const parsed = JSON.parse(result.data);
const list: FetchedModel[] = (parsed.data || parsed.models || []).map((m: { id: string; name?: string }) => ({
id: m.id,
name: m.name,
}));
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
setModels(list);
setHasFetched(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to parse response");
} finally {
setIsLoading(false);
}
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
// Auto-fetch when dropdown first opens
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
// Filter models by current input value (inline autocomplete)
const suggestions = useMemo(() => {
if (!hasFetched || models.length === 0) return [];
if (!value.trim()) return models;
const q = value.toLowerCase();
return models.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}, [models, value, hasFetched]);
const showSuggestions = isOpen && canFetch;
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
if (canFetch && hasFetched && !isOpen) setIsOpen(true);
}}
onFocus={() => { if (canFetch) setIsOpen(true); }}
onBlur={() => { setIsOpen(false); }}
placeholder={placeholder ?? (canFetch ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
canFetch && "pr-8",
)}
/>
{canFetch && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
</button>
)}
</div>
{canFetch && (
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
title={t('ai.providers.refreshModels')}
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
<div className="max-h-60 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</div>
) : error ? (
<div className="px-3 py-3 text-center text-xs text-destructive">{error}</div>
) : suggestions.length === 0 ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
{hasFetched ? t('ai.providers.noMatchingModels') : t('ai.providers.clickToLoadModels')}
</div>
) : (
suggestions.slice(0, 100).map((m) => (
<button
key={m.id}
onMouseDown={(e) => {
e.preventDefault();
onChange(m.id);
setIsOpen(false);
}}
className={cn(
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors flex items-center justify-between gap-2",
m.id === value && "bg-accent",
)}
>
<span className="font-mono truncate">{m.id}</span>
{m.id === value && <Check size={12} className="text-primary shrink-0" />}
</button>
))
)}
{suggestions.length > 100 && (
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React from "react";
import { Pencil, Trash2 } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Toggle } from "../../settings-ui";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { ProviderConfigForm } from "./ProviderConfigForm";
export const ProviderCard: React.FC<{
provider: ProviderConfig;
isActive: boolean;
onToggleEnabled: (enabled: boolean) => void;
onEdit: () => void;
onRemove: () => void;
onUpdate: (updates: Partial<ProviderConfig>) => void;
isEditing: boolean;
onCancelEdit: () => void;
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
const { t } = useI18n();
const hasApiKey = !!provider.apiKey;
return (
<div
className={cn(
"rounded-lg border p-4 transition-colors",
isActive ? "border-primary/50 bg-primary/5" : "border-border/60 bg-muted/20",
)}
>
<div className="flex items-center gap-3">
{/* Provider icon */}
<ProviderIconBadge providerId={provider.providerId} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{provider.name}</span>
{isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
{t('ai.providers.active')}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className={cn(
"text-xs",
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
)}
>
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
</span>
{provider.defaultModel && (
<>
<span className="text-muted-foreground text-xs">|</span>
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onEdit}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t('ai.providers.configure')}
>
<Pencil size={14} />
</button>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title={t('ai.providers.remove')}
>
<Trash2 size={14} />
</button>
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
</div>
</div>
{/* Expandable config form */}
{isEditing && (
<ProviderConfigForm
provider={provider}
onSave={(updates) => {
onUpdate(updates);
onCancelEdit();
}}
onCancel={onCancelEdit}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,152 @@
import React, { useCallback, useEffect, useState } from "react";
import { Check, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import type { ProviderFormState } from "./types";
import { ModelSelector } from "./ModelSelector";
export const ProviderConfigForm: React.FC<{
provider: ProviderConfig;
onSave: (updates: Partial<ProviderConfig>) => void;
onCancel: () => void;
}> = ({ provider, onSave, onCancel }) => {
const { t } = useI18n();
const [form, setForm] = useState<ProviderFormState>({
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const preset = PROVIDER_PRESETS[provider.providerId];
// Decrypt and load existing API key on mount
useEffect(() => {
if (provider.apiKey) {
setIsDecrypting(true);
decryptField(provider.apiKey)
.then((decrypted) => {
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
})
.catch(() => {
// If decryption fails, show raw value
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
})
.finally(() => setIsDecrypting(false));
}
}, [provider.apiKey]);
const handleSave = useCallback(async () => {
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
onSave(updates);
}, [form, onSave, isCustom]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
{/* Name (custom providers only) */}
{isCustom && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('ai.providers.name.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
{/* API Key */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
<input
type="text"
value={form.baseURL}
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
placeholder={preset?.defaultBaseURL || "https://"}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* Default Model */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
<ModelSelector
value={form.defaultModel}
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
modelsEndpoint={preset?.modelsEndpoint}
apiKey={form.apiKey}
providerId={provider.providerId}
skipTLSVerify={form.skipTLSVerify}
/>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.skipTLSVerify}
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>
<Check size={14} className="mr-1.5" />
{t('common.save')}
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import React from "react";
import { cn } from "../../../../lib/utils";
import type { SettingsIconId } from "./types";
import { SETTINGS_ICON_PATHS, SETTINGS_ICON_COLORS } from "./types";
export const ProviderIconBadge: React.FC<{
providerId: SettingsIconId;
size?: "sm" | "md";
}> = ({ providerId, size = "md" }) => (
<div
className={cn(
"rounded-md flex items-center justify-center shrink-0 overflow-hidden",
size === "sm" ? "w-5 h-5" : "w-8 h-8",
SETTINGS_ICON_COLORS[providerId],
)}
>
<img
src={SETTINGS_ICON_PATHS[providerId]}
alt=""
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>
</div>
);

View File

@@ -0,0 +1,204 @@
import React, { useCallback, useState } from "react";
import { Plus, Shield, X } from "lucide-react";
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
import { DEFAULT_COMMAND_BLOCKLIST } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Select, SettingRow } from "../../settings-ui";
export const SafetySettings: React.FC<{
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
commandBlocklist: string[];
setCommandBlocklist: (value: string[]) => void;
commandTimeout: number;
setCommandTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
}> = ({
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
}) => {
const { t } = useI18n();
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
if (!pattern) {
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
}
try {
new RegExp(pattern);
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
} catch (e) {
setRegexErrors((prev) => ({
...prev,
[idx]: e instanceof Error ? e.message : String(e),
}));
return false;
}
}, []);
const handlePatternChange = useCallback((value: string, idx: number) => {
const next = [...commandBlocklist];
next[idx] = value;
validatePattern(value, idx);
setCommandBlocklist(next);
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
const permissionModeOptions = [
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
{ value: "autonomous", label: t('ai.safety.permissionMode.autonomous') },
];
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.safety.title')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
<SettingRow
label={t('ai.safety.permissionMode')}
description={t('ai.safety.permissionMode.description')}
>
<Select
value={globalPermissionMode}
options={permissionModeOptions}
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
className="w-64"
/>
</SettingRow>
<SettingRow
label={t('ai.safety.commandTimeout')}
description={t('ai.safety.commandTimeout.description')}
>
<div className="flex items-center gap-2">
<input
type="number"
value={commandTimeout}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val > 0) setCommandTimeout(val);
}}
min={1}
max={3600}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
</div>
</SettingRow>
<SettingRow
label={t('ai.safety.maxIterations')}
description={t('ai.safety.maxIterations.description')}
>
<input
type="number"
value={maxIterations}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val > 0) setMaxIterations(val);
}}
min={1}
max={100}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
{/* Command Blocklist */}
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
<p className="text-xs text-muted-foreground">
{t('ai.safety.blocklist.description')}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
>
{t('ai.safety.blocklist.reset')}
</Button>
</div>
<div className="space-y-1.5">
{commandBlocklist.map((pattern, idx) => (
<div key={idx} className="space-y-0.5">
<div className="flex items-center gap-2">
<input
type="text"
value={pattern}
onChange={(e) => handlePatternChange(e.target.value, idx)}
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
regexErrors[idx]
? 'border-destructive focus-visible:ring-destructive'
: 'border-input focus-visible:ring-ring'
}`}
placeholder={t('ai.safety.blocklist.placeholder')}
/>
<button
onClick={() => {
const next = commandBlocklist.filter((_, i) => i !== idx);
setCommandBlocklist(next);
setRegexErrors((prev) => {
const updated: Record<number, string> = {};
for (const [k, v] of Object.entries(prev)) {
const ki = Number(k);
if (ki < idx) updated[ki] = v as string;
else if (ki > idx) updated[ki - 1] = v as string;
}
return updated;
});
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X size={14} />
</button>
</div>
{regexErrors[idx] && (
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
)}
</div>
))}
</div>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
>
<Plus size={14} className="mr-1" />
{t('ai.safety.blocklist.add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('ai.safety.note')}
</p>
</div>
);
};

View File

@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Globe, Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingRow } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
exa: "/ai/search/exa.png",
bocha: "/ai/search/bocha.webp",
zhipu: "/ai/search/zhipu.png",
searxng: "/ai/search/searxng.svg",
};
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
<img
src={SEARCH_ICON_PATHS[providerId]}
alt=""
className="w-4 h-4 shrink-0"
/>
);
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
WEB_SEARCH_PROVIDER_PRESETS,
).map(([id, preset]) => ({
value: id as WebSearchProviderId,
label: preset.name,
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
}));
export const WebSearchSettings: React.FC<{
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}> = ({ webSearchConfig, setWebSearchConfig }) => {
const { t } = useI18n();
const [apiKeyInput, setApiKeyInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const config = useMemo(() => webSearchConfig ?? {
providerId: "tavily" as WebSearchProviderId,
enabled: false,
maxResults: 5,
}, [webSearchConfig]);
// Ref to always read the latest config in async callbacks (avoids stale closure)
const configRef = useRef(config);
configRef.current = config;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
// Decrypt API key on mount or when provider changes (with cancellation guard)
const decryptSeqRef = useRef(0);
useEffect(() => {
if (config.apiKey) {
const seq = ++decryptSeqRef.current;
setIsDecrypting(true);
decryptField(config.apiKey)
.then((decrypted) => {
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
})
.catch(() => {
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
})
.finally(() => {
if (decryptSeqRef.current === seq) setIsDecrypting(false);
});
} else {
decryptSeqRef.current++;
setApiKeyInput("");
setIsDecrypting(false);
}
}, [config.apiKey, config.providerId]);
const updateConfig = useCallback(
(updates: Partial<WebSearchConfig>) => {
setWebSearchConfig({ ...configRef.current, ...updates });
},
[setWebSearchConfig],
);
const handleProviderChange = useCallback(
(val: string) => {
const providerId = val as WebSearchProviderId;
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
setWebSearchConfig({
...configRef.current,
providerId,
apiKey: undefined,
apiHost: newPreset.defaultApiHost || undefined,
});
setApiKeyInput("");
},
[setWebSearchConfig],
);
// Sequence counter for blur saves — prevents out-of-order encryption results
const blurSeqRef = useRef(0);
const handleApiKeyBlur = useCallback(async () => {
if (!apiKeyInput.trim()) {
blurSeqRef.current++;
updateConfig({ apiKey: undefined });
return;
}
const seq = ++blurSeqRef.current;
const providerAtBlur = configRef.current.providerId;
const encrypted = await encryptField(apiKeyInput.trim());
// Only apply if this is still the latest blur and provider hasn't changed
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
updateConfig({ apiKey: encrypted });
}
}, [apiKeyInput, updateConfig]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
{/* Enable/Disable */}
<SettingRow
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</label>
</SettingRow>
{/* Provider */}
<SettingRow
label={t("ai.webSearch.provider")}
description={t("ai.webSearch.provider.description")}
>
<Select
value={config.providerId}
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
className="w-48"
/>
</SettingRow>
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
<div className="flex items-center gap-1.5">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyInput}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
onChange={(e) => setApiKeyInput(e.target.value)}
onBlur={() => void handleApiKeyBlur()}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isDecrypting}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</SettingRow>
)}
{/* API Host */}
<SettingRow
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
? t("ai.webSearch.apiHost.searxngDescription")
: t("ai.webSearch.apiHost.description")
}
>
<input
type="text"
value={config.apiHost ?? preset.defaultApiHost}
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
placeholder={preset.defaultApiHost || "https://..."}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
{/* Max Results */}
<SettingRow
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
<input
type="number"
value={config.maxResults ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= 20) {
updateConfig({ maxResults: val });
}
}}
min={1}
max={20}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
</div>
);
};

View File

@@ -0,0 +1,8 @@
export { ProviderIconBadge } from "./ProviderIconBadge";
export { ModelSelector } from "./ModelSelector";
export { ProviderConfigForm } from "./ProviderConfigForm";
export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -0,0 +1,129 @@
/**
* Shared types for AI settings sub-components
*/
import type {
AIProviderId,
ExternalAgentConfig,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "not_logged_in"
| "unknown";
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
export interface CodexLoginSession {
sessionId: string;
state: CodexLoginState;
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
}
export interface AgentPathInfo {
path: string | null;
version: string | null;
available: boolean;
}
export interface ProviderFormState {
name: string;
apiKey: string;
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
}
export interface FetchedModel {
id: string;
name?: string;
}
export interface FetchBridge {
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
openExternal?: (url: string) => Promise<void>;
}
// Agent default configs for registration in externalAgents
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
codex: {
name: "Codex CLI",
args: ["exec", "--full-auto", "--json", "{prompt}"],
icon: "openai",
acpCommand: "codex-acp",
acpArgs: [],
},
claude: {
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
acpCommand: "claude-code-acp",
acpArgs: [],
},
};
// ---------------------------------------------------------------------------
// Bridge helpers
// ---------------------------------------------------------------------------
export function getBridge(): NetcattyAiBridge | undefined {
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
}
export function getFetchBridge(): FetchBridge | undefined {
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
}
export function normalizeCodexBridgeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
}
return message;
}
// ---------------------------------------------------------------------------
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
custom: "/ai/providers/custom.svg",
};
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",
custom: "bg-zinc-600",
};

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
@@ -55,6 +54,7 @@ interface SftpModalHeaderProps {
onToggleShowHiddenFiles: () => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
onClose?: () => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
@@ -97,6 +97,7 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onToggleShowHiddenFiles,
onUpdateHost,
onNavigateToBookmark,
onClose,
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
@@ -126,24 +127,35 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
return (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
size="sm"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
<div className="text-sm font-semibold">
{host.label}
</DialogTitle>
</div>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
</DialogHeader>
</div>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">

View File

@@ -89,6 +89,7 @@ export const useSftpModalSession = ({
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const initializingRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
@@ -316,92 +317,109 @@ export const useSftpModalSession = ({
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
initializingRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
} finally {
setLoading(false);
initializingRef.current = false;
}
})();
return;
}
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
} finally {
initializingRef.current = false;
}
})();
return;
}
void loadFiles(currentPath);
// Skip redundant loadFiles while async initialization is still in flight.
// Without this guard, dependency changes (e.g. loadFiles recreation from
// files.length change) can re-trigger this effect and call loadFiles with
// the stale currentPath before the initialization IIFE has resolved and
// updated currentPathRef — causing uploads to target the wrong directory.
if (!initializingRef.current) {
void loadFiles(currentPath);
}
} else {
loadSeqRef.current += 1;
initializedRef.current = false;
initializingRef.current = false;
}
}, [
closeSftpSession,

View File

@@ -2,10 +2,10 @@ import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import { Button } from "../ui/button";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
import { SftpTransferQueue } from "./SftpTransferQueue";
type SftpState = ReturnType<typeof useSftpState>;
@@ -13,6 +13,7 @@ interface SftpOverlaysProps {
hosts: Host[];
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showTransferQueue?: boolean;
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
@@ -46,6 +47,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
hosts,
sftp,
visibleTransfers,
showTransferQueue = true,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
@@ -98,49 +100,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
onSelectHost={handleHostSelectRight}
/>
{/* Transfer status area - shows folder uploads and file transfers */}
{sftp.transfers.length > 0 && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
</span>
)}
</span>
{sftp.transfers.some(
(t) => t.status === "completed" || t.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={sftp.clearCompletedTransfers}
>
Clear completed
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
// External uploads use a different cancel mechanism
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
/>
))}
</div>
</div>
{showTransferQueue && (
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
)}
<SftpConflictDialog

View File

@@ -1,9 +1,11 @@
import React from "react";
import { Bookmark, Check, ChevronLeft, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { SftpBreadcrumb } from "./index";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
@@ -12,7 +14,6 @@ import type { SftpBookmark } from "../../domain/models";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onNavigateUp: () => void;
onNavigateTo: (path: string) => void;
onSetFilter: (value: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
@@ -49,12 +50,17 @@ interface SftpPaneToolbarProps {
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
// bookmark ~20px, padding ~16px. Collapse early so the breadcrumb
// always gets at least ~200px of space.
const COLLAPSE_WIDTH = 400;
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
t,
pane,
onNavigateUp,
onNavigateTo,
onSetFilter,
onSetFilenameEncoding,
@@ -90,308 +96,486 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
onDeleteBookmark,
showHiddenFiles,
onToggleShowHiddenFiles,
}) => (
<>
{/* Toolbar - always visible when connected */}
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
>
<ChevronLeft size={12} />
</Button>
onGoToTerminalCwd,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
// Observe the overall toolbar width to decide whether to collapse action buttons
useEffect(() => {
const el = outerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setCollapsed(entry.contentRect.width < COLLAPSE_WIDTH);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
{/* Bookmark button with dropdown */}
<Popover>
<PopoverTrigger asChild>
const handleNewFolder = useCallback(() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}, [setNewFolderName, setShowNewFolderDialog]);
const handleNewFile = useCallback(() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}, [getNextUntitledName, pane.files, setNewFileName, setFileNameError, setShowNewFileDialog]);
const handleToggleFilter = useCallback(() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}, [showFilterBar, setShowFilterBar, filterInputRef]);
const isRemote = !pane.connection?.isLocal;
// Buttons that always remain visible (not collapsed)
const pinnedButtons = (
<>
{onGoToTerminalCwd && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
title={isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
className="h-6 w-6"
onClick={onGoToTerminalCwd}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
<TerminalSquare size={14} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</TooltipTrigger>
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={handleToggleFilter}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.filter")}</TooltipContent>
</Tooltip>
</>
);
// Collapsible action buttons (shown inline when space allows)
const collapsibleButtons = (
<>
{isRemote && (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="end">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</>
);
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (
<Popover>
<PopoverTrigger asChild>
// Overflow dropdown menu items (same collapsible actions as menu items)
const overflowMenuItems = (
<div className="flex flex-col min-w-[140px]">
{isRemote && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left">
<Languages size={14} className="shrink-0" />
{t("sftp.encoding.label")}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" side="right">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFolder}
>
<FolderPlus size={14} className="shrink-0" />
{t("sftp.newFolder")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFile}
>
<FilePlus size={14} className="shrink-0" />
{t("sftp.newFile")}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
showHiddenFiles && "text-primary",
)}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} className="shrink-0" /> : <Eye size={14} className="shrink-0" />}
{t("settings.sftp.showHiddenFiles")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn("shrink-0", (pane.loading || pane.reconnecting) && "animate-spin")}
/>
{t("common.refresh")}
</button>
</div>
);
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{/* Toolbar - always visible when connected */}
<div ref={outerRef} className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
{/* Bookmark button with dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
{/* Action buttons area - observed for overflow */}
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{collapsed ? (
<>
{pinnedButtons}
<Dropdown>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<MoreHorizontal size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t("common.more")}</TooltipContent>
</Tooltip>
<DropdownContent align="end">
{overflowMenuItems}
</DropdownContent>
</Dropdown>
</>
) : (
<>
{pinnedButtons}
{collapsibleButtons}
</>
)}
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={t("sftp.encoding.label")}
>
<Languages size={14} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="end">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}
title={t("sftp.newFile")}
>
<FilePlus size={14} />
</Button>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
title={t("settings.sftp.showHiddenFiles")}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}}
title={t("sftp.filter")}
>
<Search size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
}}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
title={t("common.close")}
>
<X size={14} />
</Button>
</div>
)}
</>
);
)}
</TooltipProvider>
);
};

View File

@@ -58,6 +58,7 @@ interface SftpPaneViewProps {
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
@@ -66,6 +67,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
}) => {
const isActive = true;
@@ -299,7 +301,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneToolbar
t={t}
pane={pane}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
@@ -335,6 +336,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onDeleteBookmark={deleteBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
/>
<SftpPaneFileList

View File

@@ -12,6 +12,7 @@ import {
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -22,9 +23,18 @@ interface SftpTransferItemProps {
onCancel: () => void;
onRetry: () => void;
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
}
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
task,
onCancel,
onRetry,
onDismiss,
canRevealTarget = false,
onRevealTarget,
}) => {
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Calculate remaining time from backend-reported sliding-window speed
@@ -49,33 +59,43 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
: '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
const details = (
<>
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm truncate font-medium">{task.fileName}</span>
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
<div
className={cn(
"text-[9px] mt-0.5 truncate",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
title={targetDirectoryPath}
>
{targetDirectoryPath}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 h-2 bg-secondary/80 rounded-full overflow-hidden">
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
@@ -100,39 +120,56 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
)}
</div>
</div>
<span className="text-[11px] text-muted-foreground shrink-0 min-w-[40px] text-right font-mono">
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
</span>
</div>
)}
{task.status === 'transferring' && bytesDisplay && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
{bytesDisplay}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[10px] text-green-600 mt-0.5">
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
</div>
)}
{task.status === 'failed' && task.error && (
<span className="text-xs text-destructive">{task.error}</span>
<span className="text-[10px] text-destructive">{task.error}</span>
)}
</div>
</>
);
return (
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
title="Open transfer destination"
>
{details}
</button>
) : (
details
)}
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDismiss} title="Dismiss">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
@@ -158,6 +195,8 @@ const arePropsEqual = (
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {

View File

@@ -0,0 +1,79 @@
import React from "react";
import { Button } from "../ui/button";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { useSftpState } from "../../application/state/useSftpState";
import type { TransferTask } from "../../types";
import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>;
interface SftpTransferQueueProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
}
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
sftp,
visibleTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
}) => {
const { t } = useI18n();
if (sftp.transfers.length === 0) {
return null;
}
return (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({t("sftp.transfers.active", { count: sftp.activeTransfersCount })})
</span>
)}
</span>
{sftp.transfers.some(
(tr) => tr.status === "completed" || tr.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[11px]"
onClick={sftp.clearCompletedTransfers}
>
{t("sftp.transfers.clearCompleted")}
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
}
: undefined
}
/>
))}
</div>
</div>
);
};

View File

@@ -30,14 +30,12 @@ export const useSftpPaneVirtualList = ({
if (!container || !isActive) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf1 = window.requestAnimationFrame(update);
const raf2 = window.requestAnimationFrame(update);
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
window.cancelAnimationFrame(raf);
};
}, [isActive, sortedDisplayFiles.length]);

View File

@@ -119,6 +119,10 @@ export const useSftpViewFileOps = ({
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
/** Host ID at the time the file was opened, to prevent saving to wrong host.
* Uses hostId (not connectionId) because auto-reconnect after a transient
* disconnect generates a fresh connectionId for the same endpoint. */
hostId?: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
@@ -148,7 +152,7 @@ export const useSftpViewFileOps = ({
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath });
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, fullPath);
@@ -242,6 +246,19 @@ export const useSftpViewFileOps = ({
async (content: string) => {
if (!textEditorTarget) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
// transient disconnect generates a fresh connectionId for the same
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = textEditorTarget.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,

View File

@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={onSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
disabled={!isValid}
onClick={onSubmit}
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
>
{t("terminal.auth.continueSave")}
</Button>
<DropdownTrigger asChild>
<Button
disabled={!isValid}
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
disabled={!isValid}
>
{t("common.continue")}
</button>
</DropdownContent>
</Dropdown>
</div>
</>
);

View File

@@ -2,7 +2,7 @@
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { User } from 'lucide-react';
import { Loader2, TerminalSquare, User } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -154,7 +154,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{'>_'}
{isConnecting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<TerminalSquare size={14} />
)}
</div>
</div>
</div>

View File

@@ -5,28 +5,19 @@
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Snippet, Host } from '../../types';
import { Host } from '../../types';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { ScrollArea } from '../ui/scroll-area';
import ThemeCustomizeModal from './ThemeCustomizeModal';
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
export interface TerminalToolbarProps {
status: 'connecting' | 'connected' | 'disconnected';
snippets: Snippet[];
host?: Host;
defaultThemeId: string;
defaultFontFamilyId: string;
defaultFontSize: number;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
isScriptsOpen: boolean;
setIsScriptsOpen: (open: boolean) => void;
onOpenSFTP: () => void;
onSnippetClick: (command: string) => void;
onOpenScripts: () => void;
onOpenTheme: () => void;
onUpdateHost?: (host: Host) => void;
showClose?: boolean;
onClose?: () => void;
@@ -43,18 +34,10 @@ export interface TerminalToolbarProps {
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
status,
snippets,
host,
defaultThemeId,
defaultFontFamilyId,
defaultFontSize,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
isScriptsOpen,
setIsScriptsOpen,
onOpenSFTP,
onSnippetClick,
onOpenScripts,
onOpenTheme,
onUpdateHost,
showClose,
onClose,
@@ -66,7 +49,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSetTerminalEncoding,
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
@@ -75,69 +57,45 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
const currentFontSize = host?.fontSize || defaultFontSize;
const handleThemeChange = (themeId: string) => {
if (isLocalTerminal) {
onUpdateTerminalThemeId?.(themeId);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, theme: themeId });
}
};
const handleFontFamilyChange = (fontFamilyId: string) => {
if (isLocalTerminal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, fontFamily: fontFamilyId });
}
};
const handleFontSizeChange = (fontSize: number) => {
if (isLocalTerminal) {
onUpdateTerminalFontSize?.(fontSize);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, fontSize });
}
};
return (
<>
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{!hidesSftp && (
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<PopoverTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.encoding")}
aria-label={t("terminal.toolbar.encoding")}
disabled={status !== 'connected'}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<Languages size={12} />
<FolderInput size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.encoding")}
>
<Languages size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.encoding")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["utf-8", "gb18030"] as const).map((enc) => (
<PopoverClose asChild key={enc}>
@@ -163,57 +121,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</Popover>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.scripts")}
aria-label={t("terminal.toolbar.scripts")}
onClick={onOpenScripts}
>
<Zap size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="px-3 py-2 text-[10px] uppercase text-muted-foreground font-semibold bg-muted/30 border-b">
{t("terminal.toolbar.library")}
</div>
<ScrollArea className="h-64">
<div className="py-1">
{snippets.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
{t("terminal.toolbar.noSnippets")}
</div>
) : (
snippets.map((s) => (
<button
key={s.id}
onClick={() => onSnippetClick(s.command)}
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex flex-col gap-0.5"
>
<span className="font-medium">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px]">
{s.command}
</span>
</button>
))
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.scripts")}</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.terminalSettings")}
aria-label={t("terminal.toolbar.terminalSettings")}
onClick={() => setThemeModalOpen(true)}
>
<Palette size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.terminalSettings")}
onClick={onOpenTheme}
>
<Palette size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.terminalSettings")}</TooltipContent>
</Tooltip>
<HostKeywordHighlightPopover
host={host}
@@ -223,59 +159,57 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
buttonClassName={buttonBase}
/>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.composeBar")}
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
>
<TextCursorInput size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
>
<TextCursorInput size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.composeBar")}</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.searchTerminal")}
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
>
<Search size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
>
<Search size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
</Tooltip>
{showClose && onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("terminal.toolbar.closeSession")}
>
<X size={11} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X size={11} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.closeSession")}</TooltipContent>
</Tooltip>
)}
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={currentThemeId}
currentFontFamilyId={currentFontFamilyId}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onFontFamilyChange={handleFontFamilyChange}
onFontSizeChange={handleFontSizeChange}
onSave={() => {
// Trigger any necessary updates
}}
/>
</>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,430 @@
/**
* ThemeSidePanel - Theme/Font customization panel for the terminal side panel
*
* Adapted from ThemeCustomizeModal's left panel content.
* No preview - the actual terminal behind serves as a live preview.
* Changes apply in real-time.
*/
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area';
type TabType = 'theme' | 'font' | 'custom';
// Memoized theme item component
const ThemeItem = memo(({
theme,
isSelected,
onSelect,
onEdit,
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit?: (id: string) => void;
}) => (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
)}
>
{/* Color swatch */}
<div
className="w-6 h-6 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-0.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">
{theme.type}
{theme.isCustom && ' • custom'}
</div>
</div>
{onEdit && (
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
>
<Pencil size={10} />
</div>
)}
{isSelected && !onEdit && (
<Check size={12} className="text-primary flex-shrink-0" />
)}
</div>
));
ThemeItem.displayName = 'ThemeItem';
// Memoized font item component
const FontItem = memo(({
font,
isSelected,
onSelect
}: {
font: TerminalFont;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(font.id)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
)}
>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"
style={{ fontFamily: font.family }}
>
{font.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
</div>
{isSelected && (
<Check size={12} className="text-primary flex-shrink-0" />
)}
</button>
));
FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
currentThemeId: string;
currentFontFamilyId: string;
currentFontSize: number;
onThemeChange: (themeId: string) => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontSizeChange: (fontSize: number) => void;
isVisible?: boolean;
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
currentThemeId,
currentFontFamilyId,
currentFontSize,
onThemeChange,
onFontFamilyChange,
onFontSizeChange,
isVisible = true,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
);
const handleThemeSelect = useCallback((themeId: string) => {
setEditingTheme(null);
onThemeChange(themeId);
}, [onThemeChange]);
const handleFontSelect = useCallback((fontId: string) => {
onFontFamilyChange(fontId);
}, [onFontFamilyChange]);
const handleFontSizeChange = useCallback((delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta));
onFontSizeChange(newSize);
}, [currentFontSize, onFontSizeChange]);
const handleNewTheme = useCallback(() => {
const base = allThemes.find(t => t.id === currentThemeId) || TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setEditingTheme(newTheme);
setIsNewTheme(true);
}, [currentThemeId, allThemes]);
const handleImportFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
onThemeChange(parsed.id);
setActiveTab('theme');
} else {
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.');
}
};
reader.readAsText(file);
e.target.value = '';
}, [addTheme, onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
}
}, [customThemes]);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
if (currentThemeId === themeId) {
onThemeChange(TERMINAL_THEMES[0].id);
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, currentThemeId, onThemeChange]);
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
return (
<>
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'theme'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'font'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'custom'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
</div>
{/* List Content */}
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{activeTab === 'theme' && (
<div>
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
))}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
{activeTab === 'font' && (
<div>
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={currentFontFamilyId === font.id}
onSelect={handleFontSelect}
/>
))}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
<div>
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-primary/10 text-primary shrink-0">
<Plus size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
<Download size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".itermcolors"
onChange={handleFileSelected}
className="hidden"
/>
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
</div>
</ScrollArea>
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<button
onClick={() => handleFontSizeChange(-1)}
disabled={currentFontSize <= MIN_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Minus size={12} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-foreground tabular-nums">{currentFontSize}</span>
<span className="text-[9px] text-muted-foreground">px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={currentFontSize >= MAX_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Plus size={12} />
</button>
</div>
</div>
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t border-border/50 shrink-0">
<div className="text-[9px] text-muted-foreground truncate">
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
</div>
</div>
</div>
{/* Custom Theme Editor Modal */}
{editingTheme && (
<CustomThemeModal
open={!!editingTheme}
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
if (isNewTheme) {
addTheme(theme);
onThemeChange(theme.id);
} else {
updateTheme(theme.id, theme);
if (currentThemeId === theme.id) {
onThemeChange(theme.id);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}}
onDelete={isNewTheme ? undefined : handleEditorDelete}
onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }}
/>
)}
</>
);
};
export const ThemeSidePanel = memo(ThemeSidePanelInner);
ThemeSidePanel.displayName = 'ThemeSidePanel';

View File

@@ -11,6 +11,9 @@ import {
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
/** Timeout of distro detection task */
const DISTRO_DETECT_TIMEOUT = 8000; // ms
type TerminalBackendApi = {
backendAvailable: () => boolean;
telnetAvailable: () => boolean;
@@ -38,7 +41,7 @@ type TerminalBackendApi = {
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
onSessionExit: (
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void,
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
@@ -61,6 +64,12 @@ type ChainProgressState = {
currentHostLabel: string;
} | null;
export type SessionLogConfig = {
enabled: boolean;
directory: string;
format: string;
};
export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
@@ -68,10 +77,12 @@ export type TerminalSessionStartersContext = {
resolvedChainHosts: Host[];
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
terminalSettings?: TerminalSettings;
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionLog?: SessionLogConfig;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;
@@ -96,7 +107,7 @@ export type TerminalSessionStartersContext = {
t?: (key: string) => string;
onSessionAttached?: (sessionId: string) => void;
onSessionExit?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCommandExecuted?: (
@@ -209,13 +220,13 @@ const attachSessionToTerminal = (
}
}
ctx.onSessionExit?.(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
auth: { username: string; password?: string; key?: SSHKey },
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
) => {
if (!ctx.terminalBackend.execAvailable()) return;
try {
@@ -225,8 +236,9 @@ const runDistroDetection = async (
port: ctx.host.port || 22,
password: auth.password,
privateKey: auth.key?.privateKey,
passphrase: auth.passphrase ?? auth.key?.passphrase,
command: "cat /etc/os-release 2>/dev/null || uname -a",
timeout: 8000,
timeout: DISTRO_DETECT_TIMEOUT,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
@@ -451,6 +463,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
};
@@ -535,8 +548,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Guard against stale timers: if the session changed (e.g. user
// clicked Start Over quickly), skip to avoid double execution
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
@@ -573,6 +587,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
@@ -602,6 +617,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -643,6 +659,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: moshEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -656,8 +673,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
@@ -700,6 +718,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
ctx.sessionRef.current = id;
@@ -746,7 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
}
ctx.onSessionExit?.(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -779,6 +798,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
// Serial connection is established immediately when session starts

View File

@@ -94,6 +94,9 @@ export type CreateXTermRuntimeContext = {
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
onOsc52ReadRequest?: () => Promise<boolean>;
};
const detectPlatform = (): XTermPlatform => {
@@ -384,12 +387,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = `${normalizeLineEndings(snippet.command)}\r`;
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
}
if (ctx.onCommandExecuted) {
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
@@ -590,7 +595,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
term.parser.registerOscHandler(7, (data) => {
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
try {
// data is the content after "7;" - typically "file://hostname/path"
if (data.startsWith('file://')) {
@@ -614,6 +619,78 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true; // Indicate we handled the sequence
});
// OSC 52 — clipboard integration
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
// <target> is typically "c" (clipboard) or "p" (primary selection)
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const settings = ctx.terminalSettingsRef.current;
const mode = settings?.osc52Clipboard ?? 'write-only';
if (mode === 'off') return true;
try {
const semi = data.indexOf(';');
if (semi < 0) return true;
const target = data.substring(0, semi);
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
if (target !== 'c' && target !== '') return true;
const payload = data.substring(semi + 1);
if (payload === '?') {
// Read request — allowed in read-write mode, or prompt user in prompt mode
if (mode !== 'read-write' && mode !== 'prompt') {
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
return true;
}
const sessionId = ctx.sessionRef.current;
if (!sessionId) return true;
// Use Electron bridge as primary, fall back to navigator.clipboard
const readClipboard = async (): Promise<string> => {
try {
const bridge = netcattyBridge.get();
if (bridge?.readClipboardText) return await bridge.readClipboardText();
} catch { /* fall through to navigator.clipboard */ }
return navigator.clipboard.readText();
};
const doRead = async () => {
// In prompt mode, ask user first
if (mode === 'prompt') {
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
if (!allowed) {
logger.debug('[XTerm] OSC 52 read denied by user');
return;
}
}
const text = await readClipboard();
// Chunked base64 encoding to avoid stack overflow on large payloads
const bytes = new TextEncoder().encode(text);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
const b64 = btoa(binary);
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
};
doRead().catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
});
return true;
}
// Write: payload is base64-encoded UTF-8 text
const binary = atob(payload);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
navigator.clipboard.writeText(text).catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
});
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
} catch (err) {
logger.warn('[XTerm] Failed to handle OSC 52:', err);
}
return true;
});
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
@@ -638,6 +715,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -0,0 +1,91 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex flex-col rounded-[22px] border border-border/65 bg-background/92 shadow-[0_18px_42px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.04)] transition-[border-color,background-color,box-shadow]',
'supports-[backdrop-filter]:backdrop-blur-sm',
'focus-within:border-primary/45 focus-within:bg-background focus-within:ring-1 focus-within:ring-primary/20',
'overflow-hidden',
className,
)}
{...props}
/>
),
);
InputGroup.displayName = 'InputGroup';
export type InputGroupTextareaProps = ComponentProps<'textarea'>;
export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'w-full resize-none bg-transparent text-[13px] text-foreground/92 selection:bg-primary/25',
'placeholder:text-muted-foreground/62 placeholder:font-medium placeholder:text-[13px]',
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
'px-4 pt-3.5 pb-2 leading-[20px]',
'field-sizing-content min-h-[82px] max-h-52',
className,
)}
{...props}
/>
),
);
InputGroupTextarea.displayName = 'InputGroupTextarea';
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
align?: 'block-start' | 'block-end';
};
export const InputGroupAddon = forwardRef<HTMLDivElement, InputGroupAddonProps>(
({ className, align = 'block-end', ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex items-center px-2.5 py-1.5',
align === 'block-start' && 'border-b border-border/35 bg-muted/8',
align === 'block-end' && 'border-t border-border/60 bg-muted/10',
className,
)}
{...props}
/>
),
);
InputGroupAddon.displayName = 'InputGroupAddon';
export type InputGroupButtonProps = ComponentProps<'button'> & {
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
size?: 'sm' | 'icon-sm' | 'default';
};
export const InputGroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(
({ className, variant = 'ghost', size = 'icon-sm', disabled, ...props }, ref) => (
<button
ref={ref}
type="button"
disabled={disabled}
className={cn(
'inline-flex items-center justify-center rounded-md transition-colors cursor-pointer',
'disabled:opacity-30 disabled:cursor-default',
size === 'icon-sm' && 'h-7 w-7',
size === 'sm' && 'h-7 px-2 text-[12px] gap-1',
size === 'default' && 'h-8 px-3 text-[13px] gap-1.5',
variant === 'ghost' && 'text-muted-foreground/78 hover:text-foreground hover:bg-muted/45',
variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary',
variant === 'outline' && 'border border-border/40 text-muted-foreground/85 hover:text-foreground hover:bg-muted/35',
variant === 'destructive' && 'text-destructive/70 hover:text-destructive hover:bg-destructive/10',
className,
)}
{...props}
/>
),
);
InputGroupButton.displayName = 'InputGroupButton';

View File

@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />

View File

@@ -0,0 +1,9 @@
import { cn } from '../../lib/utils';
import { Loader2 } from 'lucide-react';
import type { ComponentProps } from 'react';
export type SpinnerProps = ComponentProps<typeof Loader2>;
export const Spinner = ({ className, size = 16, ...props }: SpinnerProps) => (
<Loader2 className={cn('animate-spin', className)} size={size} {...props} />
);

View File

@@ -149,6 +149,7 @@ export interface Snippet {
package?: string; // package path
targets?: string[]; // host ids
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
}
export interface TerminalLine {
@@ -168,6 +169,8 @@ export interface GroupNode {
path: string;
children: Record<string, GroupNode>;
hosts: Host[];
/** Pre-computed total host count including all descendants. Set during tree construction. */
totalHostCount?: number;
}
export interface SyncConfig {
@@ -434,6 +437,9 @@ export interface TerminalSettings {
// Paste
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
// Clipboard
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
}
@@ -541,6 +547,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
};
@@ -582,6 +589,7 @@ export interface TerminalSession {
status: 'connecting' | 'connected' | 'disconnected';
workspaceId?: string;
startupCommand?: string; // Command to run after connection (for snippet runner)
noAutoRun?: boolean; // If true, paste command without auto-executing
// Connection-time protocol overrides (used instead of looking up from hosts)
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
port?: number;
@@ -664,6 +672,10 @@ export interface TransferTask {
targetPath: string;
sourceConnectionId: string;
targetConnectionId: string;
targetHostId?: string;
/** Full endpoint key (hostId:hostname:port:protocol) for distinguishing
* same-hostId uploads with different session-time overrides. */
targetConnectionKey?: string;
direction: TransferDirection;
status: TransferStatus;
totalBytes: number;

View File

@@ -31,9 +31,10 @@ export type SyncState =
/**
* Conflict Resolution Strategy
*/
export type ConflictResolution =
| 'USE_REMOTE' // Download cloud data, overwrite local
| 'USE_LOCAL'; // Upload local data, overwrite cloud
export type ConflictResolution =
| 'USE_REMOTE' // Download cloud data, overwrite local
| 'USE_LOCAL' // Upload local data, overwrite cloud
| 'AUTO_MERGED'; // Three-way merge was applied automatically
// ============================================================================
// Cloud Provider Types
@@ -52,6 +53,7 @@ export interface WebDAVConfig {
username?: string;
password?: string;
token?: string;
allowInsecure?: boolean;
}
export interface S3Config {
@@ -171,15 +173,33 @@ export interface SyncPayload {
// Settings
settings?: {
// Theme & Appearance
theme?: 'light' | 'dark' | 'system';
accentColor?: string;
lightUiThemeId?: string;
darkUiThemeId?: string;
accentMode?: 'theme' | 'custom';
customAccent?: string;
uiFontFamilyId?: string;
uiLanguage?: string;
customCSS?: string;
// Terminal
terminalTheme?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
hotkeyScheme?: string;
terminalSettings?: Record<string, unknown>;
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
// Keyboard
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
// Editor
editorWordWrap?: boolean;
// SFTP
sftpDoubleClickBehavior?: 'open' | 'transfer';
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
};
// Sync metadata
syncedAt: number; // When this payload was created
}
@@ -257,10 +277,12 @@ export interface UnlockedMasterKey {
export interface SyncResult {
success: boolean;
provider: CloudProvider;
action: 'upload' | 'download' | 'none';
action: 'upload' | 'download' | 'merge' | 'none';
version?: number;
error?: string;
conflictDetected?: boolean;
/** Present when action === 'merge'; caller should apply this to update local state */
mergedPayload?: import('./sync').SyncPayload;
}
/**
@@ -294,7 +316,7 @@ export interface SyncHistoryEntry {
id: string;
timestamp: number;
provider: CloudProvider;
action: 'upload' | 'download' | 'conflict_resolved';
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
success: boolean;
localVersion: number;
remoteVersion?: number;
@@ -387,6 +409,7 @@ export const SYNC_STORAGE_KEYS = {
PROVIDER_S3: 'netcatty_provider_s3_v1',
PROVIDER_SMB: 'netcatty_provider_smb_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
} as const;
// ============================================================================

432
domain/syncMerge.ts Normal file
View File

@@ -0,0 +1,432 @@
/**
* Three-Way Merge for Cloud Sync Payloads
*
* Implements a Git-style three-way merge using a stored "base" snapshot
* (the last successfully synced payload) to detect per-entity changes
* on both the local and remote sides.
*
* Algorithm:
* For each entity (identified by `id`):
* - Only in local → local addition → keep
* - Only in remote → remote addition → keep
* - In base, removed locally → local deletion → remove (unless remote modified)
* - In base, removed remotely → remote deletion → remove (unless local modified)
* - Modified only locally → keep local version
* - Modified only remotely → keep remote version
* - Modified on both sides → prefer local (conflict logged)
*
* When no base is available (first sync), falls back to a set-union
* merge by entity ID, preferring local for duplicates.
*/
import type { SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface MergeSummary {
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number; conflicts: number };
}
export interface MergeResult {
payload: SyncPayload;
/** True when both sides modified the same entity (resolved by preferring local) */
hadConflicts: boolean;
summary: MergeSummary;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Deterministic JSON string for content comparison.
* Sorts object keys to avoid false diffs from key ordering.
*/
function fingerprint(value: unknown): string {
return JSON.stringify(value, (_key, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = (v as Record<string, unknown>)[k];
return acc;
}, {});
}
return v;
});
}
// ---------------------------------------------------------------------------
// Entity-array merge (hosts, keys, identities, snippets, etc.)
// ---------------------------------------------------------------------------
interface EntityMergeResult<T> {
merged: T[];
conflicts: number;
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number };
}
function mergeEntityArrays<T extends { id: string }>(
base: T[],
local: T[],
remote: T[],
): EntityMergeResult<T> {
const baseMap = new Map(base.map((e) => [e.id, e]));
const localMap = new Map(local.map((e) => [e.id, e]));
const remoteMap = new Map(remote.map((e) => [e.id, e]));
const allIds = new Set([
...baseMap.keys(),
...localMap.keys(),
...remoteMap.keys(),
]);
const merged: T[] = [];
let conflicts = 0;
const added = { local: 0, remote: 0 };
const deleted = { local: 0, remote: 0 };
const modified = { local: 0, remote: 0 };
for (const id of allIds) {
const baseItem = baseMap.get(id);
const localItem = localMap.get(id);
const remoteItem = remoteMap.get(id);
const inBase = baseItem !== undefined;
const inLocal = localItem !== undefined;
const inRemote = remoteItem !== undefined;
if (!inBase && inLocal && !inRemote) {
// Local addition
merged.push(localItem);
added.local++;
} else if (!inBase && !inLocal && inRemote) {
// Remote addition
merged.push(remoteItem);
added.remote++;
} else if (!inBase && inLocal && inRemote) {
// Both added same ID — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
} else if (inBase && inLocal && inRemote) {
// Exists in all three — compare changes
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (!localChanged && !remoteChanged) {
merged.push(baseItem);
} else if (localChanged && !remoteChanged) {
merged.push(localItem);
modified.local++;
} else if (!localChanged && remoteChanged) {
merged.push(remoteItem);
modified.remote++;
} else {
// Both changed — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
modified.local++;
modified.remote++;
}
} else if (inBase && !inLocal && inRemote) {
// Local deleted
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (remoteChanged) {
// Remote modified + local deleted → keep modification (safer)
merged.push(remoteItem);
conflicts++;
} else {
deleted.local++;
}
} else if (inBase && inLocal && !inRemote) {
// Remote deleted
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
if (localChanged) {
// Local modified + remote deleted → keep modification (safer)
merged.push(localItem);
conflicts++;
} else {
deleted.remote++;
}
}
// inBase && !inLocal && !inRemote → both deleted → gone
}
return { merged, conflicts, added, deleted, modified };
}
// ---------------------------------------------------------------------------
// String-array merge (customGroups, snippetPackages)
// ---------------------------------------------------------------------------
function mergeStringArrays(
base: string[],
local: string[],
remote: string[],
): string[] {
const baseSet = new Set(base);
const localSet = new Set(local);
const remoteSet = new Set(remote);
const result = new Set<string>();
// Start with base items, then apply additions/deletions
const allValues = new Set([...baseSet, ...localSet, ...remoteSet]);
for (const value of allValues) {
const inBase = baseSet.has(value);
const inLocal = localSet.has(value);
const inRemote = remoteSet.has(value);
if (!inBase) {
// Addition — keep if either side added it
if (inLocal || inRemote) result.add(value);
} else {
// Was in base — keep unless both sides deleted
const localDeleted = !inLocal;
const remoteDeleted = !inRemote;
if (localDeleted && remoteDeleted) {
// Both deleted — gone
} else if (localDeleted || remoteDeleted) {
// Only one side deleted — honour the deletion
// (If the other side didn't touch it, it's still in their set from base)
} else {
result.add(value);
}
}
}
return [...result];
}
// ---------------------------------------------------------------------------
// Settings merge (flat key-value)
// ---------------------------------------------------------------------------
type SettingsObj = NonNullable<SyncPayload['settings']>;
/** Check if an array contains objects with `id` fields (for entity merge). */
function isIdArray(arr: unknown[]): boolean {
return arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null && 'id' in arr[0];
}
/** Recursively merge two plain objects against a base using three-way logic. */
function mergeSettingsDeep(
base: Record<string, unknown>,
local: Record<string, unknown>,
remote: Record<string, unknown>,
): Record<string, unknown> {
const allKeys = new Set([
...Object.keys(base),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = base[key];
const lVal = local[key];
const rVal = remote[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — recurse if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return merged;
}
function mergeSettings(
base: SettingsObj | undefined,
local: SettingsObj | undefined,
remote: SettingsObj | undefined,
): SettingsObj | undefined {
if (!local && !remote) return undefined;
if (!local) return remote;
if (!remote) return local;
const b = base ?? {};
const allKeys = new Set([
...Object.keys(b),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = (b as Record<string, unknown>)[key];
const lVal = (local as Record<string, unknown>)[key];
const rVal = (remote as Record<string, unknown>)[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — deep merge if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (
Array.isArray(lVal) && Array.isArray(rVal) &&
(isIdArray(lVal) || isIdArray(rVal) || isIdArray(Array.isArray(bVal) ? bVal as unknown[] : []))
) {
// Array of objects with `id` (e.g. customTerminalThemes) — entity merge
const bArr = Array.isArray(bVal) ? bVal as Array<{ id: string }> : [];
const result = mergeEntityArrays(bArr, lVal as Array<{ id: string }>, rVal as Array<{ id: string }>);
merged[key] = result.merged;
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
// ---------------------------------------------------------------------------
// Main merge function
// ---------------------------------------------------------------------------
/**
* Three-way merge of sync payloads.
*
* @param base - The last successfully synced payload (null if unavailable)
* @param local - The current device's data
* @param remote - The other device's data (downloaded from cloud)
*/
export function mergeSyncPayloads(
base: SyncPayload | null,
local: SyncPayload,
remote: SyncPayload,
): MergeResult {
const emptyBase: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
};
const b = base ?? emptyBase;
const summary: MergeSummary = {
added: { local: 0, remote: 0 },
deleted: { local: 0, remote: 0 },
modified: { local: 0, remote: 0, conflicts: 0 },
};
// Merge each entity type
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
const knownHostSeen = new Set<string>();
const knownHosts = {
...knownHostsRaw,
merged: knownHostsRaw.merged.filter((kh) => {
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
if (knownHostSeen.has(fp)) return false;
knownHostSeen.add(fp);
return true;
}),
};
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
remote.portForwardingRules ?? [],
);
// Aggregate stats
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
summary.deleted.local += r.deleted.local;
summary.deleted.remote += r.deleted.remote;
summary.modified.local += r.modified.local;
summary.modified.remote += r.modified.remote;
summary.modified.conflicts += r.conflicts;
}
// Merge string arrays
const customGroups = mergeStringArrays(
b.customGroups ?? [],
local.customGroups ?? [],
remote.customGroups ?? [],
);
const snippetPackages = mergeStringArrays(
b.snippetPackages ?? [],
local.snippetPackages ?? [],
remote.snippetPackages ?? [],
);
// Merge settings
const settings = mergeSettings(b.settings, local.settings, remote.settings);
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
settings,
syncedAt: Date.now(),
};
return {
payload,
hadConflicts: summary.modified.conflicts > 0,
summary,
};
}

View File

@@ -16,6 +16,29 @@ import type {
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_CUSTOM_THEMES,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
// Input types
@@ -38,6 +61,160 @@ export interface SyncPayloadImporters {
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
/** Called after synced settings have been written to localStorage. */
onSettingsApplied?: () => void;
}
// ---------------------------------------------------------------------------
// Settings sync helpers
// ---------------------------------------------------------------------------
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
] as const;
/**
* Collect all syncable settings from localStorage.
*/
export function collectSyncableSettings(): SyncPayload['settings'] {
const settings: SyncPayload['settings'] = {};
// Theme & Appearance
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
if (lightUi) settings.lightUiThemeId = lightUi;
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
if (darkUi) settings.darkUiThemeId = darkUi;
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accent) settings.customAccent = accent;
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
if (uiFont) settings.uiFontFamilyId = uiFont;
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
if (lang) settings.uiLanguage = lang;
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
if (css != null) settings.customCSS = css;
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (termSettingsRaw) {
try {
const full = JSON.parse(termSettingsRaw);
const subset: Record<string, unknown> = {};
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in full) subset[key] = full[key];
}
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
if (customThemesRaw) {
try {
const parsed = JSON.parse(customThemesRaw);
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
} catch { /* ignore */ }
}
// Keyboard
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (kb) {
try {
settings.customKeyBindings = JSON.parse(kb);
} catch { /* ignore */ }
}
// Editor
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
// SFTP
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
return Object.keys(settings).length > 0 ? settings : undefined;
}
/**
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
// Terminal settings — merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
let existing: Record<string, unknown> = {};
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
if (raw) {
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
// Custom terminal themes
if (settings.customTerminalThemes != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
}
// Keyboard
if (settings.customKeyBindings != null) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
}
// Editor
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
// SFTP
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
}
// ---------------------------------------------------------------------------
@@ -64,6 +241,7 @@ export function buildSyncPayload(
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
portForwardingRules,
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}
@@ -105,4 +283,10 @@ export function applySyncPayload(
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
importers.onSettingsApplied?.();
}
}

View File

@@ -20,7 +20,18 @@ module.exports = {
asarUnpack: [
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*'
'node_modules/cpu-features/**/*',
'node_modules/@zed-industries/codex-acp/**/*',
'node_modules/@zed-industries/codex-acp-*/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
'node_modules/zod/**/*',
'node_modules/zod-to-json-schema/**/*',
'node_modules/ajv/**/*',
'node_modules/ajv-formats/**/*',
'node_modules/fast-deep-equal/**/*',
'node_modules/fast-uri/**/*',
'node_modules/json-schema-traverse/**/*',
'electron/mcp/**/*'
],
mac: {
target: [

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