Compare commits

...

46 Commits

Author SHA1 Message Date
bincxz
f4bbe62a1d fix: eliminate scroll bounce when switching tabs with AI chat open
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
StickToBottom was configured with initial="smooth", causing a visible
elastic scroll animation every time the chat panel remounted on tab
switch. Change to initial="instant" so the scroll position snaps
immediately without animation. Streaming and resize still use smooth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:43:06 +08:00
陈大猫
57e131a16e feat: support mouse wheel zoom in image preview (#409)
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:34:40 +08:00
bincxz
ea6f9e138c feat: support mouse wheel zoom in image preview
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:33:51 +08:00
陈大猫
5177ce2028 feat: image preview enhancements — zoom, drag, reset (#408)
* fix: remove padding around image in preview modal

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

* feat: add zoom controls and constrain image preview modal size

- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container

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

* feat: improve image preview with aligned controls, drag-pan, animation

- Put filename, zoom controls, and close button in a single flex row
  so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
  from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image

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

* fix: use transform scale for smooth zoom animation

Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.

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

* feat: allow drag at any zoom level and add reset button

- Remove zoom > 100 restriction on drag — image can be panned at any
  zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
  separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area

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

* fix: replace backdrop blur with box-shadow for image preview modal

Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.

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

* perf: use refs for drag state to avoid rerendering chat list

Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.

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

* fix: add aria-labels to image preview controls for accessibility

Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.

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

* fix: reset button restores drag position and stays enabled after drag

Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.

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

* fix: prevent stuck drag state on pointer cancel or lost capture

If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag

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 19:25:49 +08:00
bincxz
9f44112479 fix: prevent stuck drag state on pointer cancel or lost capture
If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:02:57 +08:00
bincxz
6999f362a3 fix: reset button restores drag position and stays enabled after drag
Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:55:46 +08:00
bincxz
fc546c2430 fix: add aria-labels to image preview controls for accessibility
Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:48:19 +08:00
bincxz
f7e4953038 perf: use refs for drag state to avoid rerendering chat list
Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:04:46 +08:00
bincxz
922376fa06 fix: replace backdrop blur with box-shadow for image preview modal
Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:50:51 +08:00
bincxz
3d4ca46c9b feat: allow drag at any zoom level and add reset button
- Remove zoom > 100 restriction on drag — image can be panned at any
  zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
  separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:49:55 +08:00
bincxz
1d8f203f5b fix: use transform scale for smooth zoom animation
Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:47:10 +08:00
bincxz
41d079a806 feat: improve image preview with aligned controls, drag-pan, animation
- Put filename, zoom controls, and close button in a single flex row
  so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
  from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:44:58 +08:00
bincxz
93c95959d3 feat: add zoom controls and constrain image preview modal size
- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:41:27 +08:00
bincxz
e7300429f8 fix: remove padding around image in preview modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:37:54 +08:00
陈大猫
c7743d082a feat: click-to-preview for images in AI chat (#407)
* feat: add click-to-preview for images in AI chat

Uploaded images in AI chat messages can now be clicked to open a
full-size lightbox preview. Clicking the overlay or the image again
dismisses it. Uses the existing Radix Dialog component.

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

* fix: use standard dialog style for image preview with close button

Replace transparent borderless overlay with proper windowed dialog that
has a background, border, and the built-in close button (X) in the
top-right corner. Remove focus ring that caused the blue border.

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

* fix: add title bar with filename and blurred backdrop to image preview

- Show filename in dialog header with border separator
- Add overlayClassName prop to DialogContent for per-instance overlay
  customization (e.g. backdrop blur, custom background)
- Apply semi-transparent black background with backdrop-blur on overlay

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

* fix: align title and close button vertically in image preview

Adjust header padding and close button position so the filename and
X button sit on the same visual line.

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 17:23:21 +08:00
陈大猫
56a4fe905d fix: handle Windows spawn for Claude ACP bundled JS binary (#405)
* fix: handle Windows spawn for Claude ACP bundled JS binary

On Windows, child_process.spawn does not interpret shebangs, so spawning
a .js file directly (like claude-agent-acp's dist/index.js) fails with
ENOENT. The @mcpc-tech/acp-ai-provider uses raw spawn() internally.

Change resolveClaudeAcpBinaryPath to return { command, prependArgs } so
that on Windows the resolved .js script is invoked via process.execPath
(Node) with the script path prepended to args. On macOS/Linux the
shebang works natively so the script is spawned directly as before.

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

* fix: use system Node instead of process.execPath on Windows

In packaged Electron builds, process.execPath points to the app binary
(e.g. Netcatty.exe), not a Node runtime. Additionally, main.cjs deletes
ELECTRON_RUN_AS_NODE at startup and the agent spawn handler blocks it
in DANGEROUS_ENV_KEYS.

Resolve the real `node` from PATH instead. If Node is not installed,
fall back to the bare `claude-agent-acp` command name so the system
can find the npm-generated .cmd wrapper.

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

* fix: use script path for display and probe version correctly on Windows

In discovery, when resolveClaudeAcpBinaryPath returns { command: node,
prependArgs: [scriptPath] }, use the script path for UI display and
dedup, and probe version with the full command (node script --version)
instead of running node --version.

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 17:00:23 +08:00
陈大猫
b17775307f fix: bundle claude-code-acp to prevent crash when binary is missing (#404)
* fix: bundle claude-code-acp to prevent crash when binary is missing (#400)

When users select Claude Code in the AI module, the app spawns
`claude-code-acp` via ACP. Previously only the `claude` CLI was checked
during agent discovery, so if `claude-code-acp` was not on PATH the
spawn would fail with ENOENT and crash the Electron main process.

- Add `@zed-industries/claude-code-acp` as a bundled dependency
- Add `resolveClaudeAcpBinaryPath()` that checks PATH first, then
  falls back to the npm-bundled binary (mirrors Codex pattern)
- Use the resolver in both the primary and fallback ACP provider paths
- Update agent discovery to detect agents via bundled ACP binary when
  the standalone CLI is not installed

Closes #400

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

* fix: add claude-code-acp and its deps to asarUnpack

In packaged Electron builds, files inside app.asar cannot be executed
by child_process.spawn. Add claude-code-acp and its runtime dependencies
to asarUnpack so the binary is accessible in production.

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

* fix: migrate from deprecated claude-code-acp to claude-agent-acp

The @zed-industries/claude-code-acp package has been renamed to
@zed-industries/claude-agent-acp (bin: claude-agent-acp). Update all
references across the codebase:

- package.json: replace dep with @zed-industries/claude-agent-acp@0.22.2
- electron-builder.config.cjs: update asarUnpack entries, remove stale
  deps (diff, minimatch) no longer needed by the new package
- shellUtils.cjs: update binary name and require.resolve path
- aiBridge.cjs: update acpCommand, ALLOWED_AGENT_COMMANDS, isClaudeAgent
- settings types, i18n locales: update command references

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 16:24:29 +08:00
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
yuzifu
a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +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
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
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
65 changed files with 1715 additions and 365 deletions

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ coverage
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)

30
App.tsx
View File

@@ -185,6 +185,7 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -1067,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) => {
@@ -1324,8 +1306,12 @@ function App({ settings }: { settings: SettingsState }) {
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

@@ -5,6 +5,9 @@ const en: Messages = {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
@@ -745,6 +748,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}',
@@ -1548,7 +1558,7 @@ const en: Messages = {
// 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.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',

View File

@@ -5,6 +5,9 @@ const zhCN: Messages = {
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
@@ -1060,6 +1063,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}',
@@ -1563,7 +1573,7 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',

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

@@ -1,66 +0,0 @@
/**
* useImageUpload - Handle image paste/drop with base64 conversion
*
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
export interface UploadedImage {
id: string;
filename: string;
dataUrl: string; // data:image/...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png"
}
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 useImageUpload() {
const [images, setImages] = useState<UploadedImage[]>([]);
const addImages = useCallback(async (files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) return;
const newImages: UploadedImage[] = await Promise.all(
imageFiles.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `screenshot-${Date.now()}.png`;
const mediaType = file.type || 'image/png';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useImageUpload] Failed to convert:', err);
}
return { id, filename, dataUrl, base64Data, mediaType };
}),
);
setImages((prev) => [...prev, ...newImages]);
}, []);
const removeImage = useCallback((id: string) => {
setImages((prev) => prev.filter((i) => i.id !== id));
}, []);
const clearImages = useCallback(() => {
setImages([]);
}, []);
return { images, addImages, removeImage, clearImages };
}

View File

@@ -21,6 +21,7 @@ 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,
@@ -63,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;
@@ -231,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>(() => {
@@ -393,6 +399,8 @@ export const useSettingsState = () => {
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();
@@ -529,6 +537,9 @@ export const useSettingsState = () => {
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 {
@@ -694,6 +705,13 @@ 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';
@@ -712,7 +730,7 @@ export const useSettingsState = () => {
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, globalHotkeyEnabled, autoUpdateEnabled, 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);
@@ -797,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');
@@ -1019,6 +1043,8 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1052,7 +1078,7 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
]),
};

View File

@@ -20,7 +20,7 @@ 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 { useImageUpload } from '../application/state/useImageUpload';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIPermissionMode,
AISession,
@@ -104,6 +104,11 @@ interface AIChatSidePanelProps {
username?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
// Visibility
isVisible?: boolean;
@@ -179,6 +184,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -196,16 +202,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const { images, addImages, removeImage, clearImages } = useImageUpload();
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
const scopeTypeRef = useRef(scopeType);
scopeTypeRef.current = scopeType;
const scopeTargetIdRef = useRef(scopeTargetId);
scopeTargetIdRef.current = scopeTargetId;
const scopeLabelRef = useRef(scopeLabel);
scopeLabelRef.current = scopeLabel;
const resolveExecutorContextRef = useRef(resolveExecutorContext);
resolveExecutorContextRef.current = resolveExecutorContext;
// ── Streaming hook ──
const {
@@ -405,8 +407,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const imagesRef = useRef(images);
imagesRef.current = images;
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) => {
@@ -416,11 +418,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [updateSessionTitle]);
const getExecutorContext = useCallback((): ExecutorContext => ({
sessions: terminalSessionsRef.current,
workspaceId: scopeTypeRef.current === 'workspace' ? scopeTargetIdRef.current : undefined,
workspaceName: scopeTypeRef.current === 'workspace' ? scopeLabelRef.current : undefined,
}), []);
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 => {
@@ -455,16 +465,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const sessionId = ensureSession();
// Capture images before clearing
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
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,
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearImages();
clearFiles();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -487,7 +497,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
@@ -504,6 +514,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
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,
@@ -514,19 +529,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
setPendingApproval,
autoTitleSession,
});
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearImages,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, getExecutorContext, setPendingApproval,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
]);
const handleStop = useCallback(() => {
@@ -639,19 +654,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
@@ -700,9 +707,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
images={images}
onAddImages={addImages}
onRemoveImage={removeImage}
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}

View File

@@ -61,16 +61,6 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
}
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
const TreeNode: React.FC<TreeNodeProps> = ({
node,
@@ -100,7 +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 = useMemo(() => countAllHostsInNode(node), [node]);
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => {
if (!node.children) return [];

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

@@ -152,6 +152,8 @@ interface TerminalProps {
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
@@ -209,6 +211,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -487,6 +490,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
sessionLog,
});
sessionStartersRef.current = sessionStarters;

View File

@@ -108,8 +108,13 @@ interface TerminalLayerProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
// Session log settings for real-time streaming
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -153,8 +158,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -167,8 +176,56 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCloseSession(sessionId);
}, [onCloseSession]);
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
@@ -244,6 +301,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
activeTabIdRef.current = activeTabId;
const activeWorkspaceRef = useRef(activeWorkspace);
activeWorkspaceRef.current = activeWorkspace;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
@@ -965,6 +1028,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return result;
}, [sessions, hosts, activeWorkspace, activeSession]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => {
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
return {
sessionId: sid,
hostId: session?.hostId || '',
hostname: host?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os,
username: host?.username,
connected: session?.status === 'connected',
};
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
};
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -1360,6 +1461,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
terminalSessions={aiTerminalSessions}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
)}
@@ -1512,6 +1614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
/>
</div>
);
@@ -1613,6 +1716,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&

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,8 +721,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
customGroups.forEach((path) => insertPath(path));
hosts.forEach((host) => insertPath(host.group || "General", host));
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [hosts, customGroups]);
}, [hosts, customGroups, countAllHostsInNode]);
// Generate all possible group paths from the tree (including all intermediate nodes)
const allGroupPaths = useMemo(() => {
@@ -896,19 +908,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
insertPath(host.group, host);
}
});
return root;
}, [treeViewHosts, customGroups]);
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [treeViewHosts, customGroups, countAllHostsInNode]);
// Create tree view specific group tree that excludes ungrouped hosts
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
@@ -1749,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
initial="instant"
resize="smooth"
role="log"
{...props}

View File

@@ -26,7 +26,7 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
<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-4 group-[.is-user]:py-2.5',
'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,
)}

View File

@@ -11,7 +11,7 @@ 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 { UploadedImage } from '../../application/state/useImageUpload';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
@@ -40,12 +40,12 @@ interface ChatInputProps {
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached images */
images?: UploadedImage[];
/** Callback to add images (paste/drop) */
onAddImages?: (files: File[]) => void;
/** Callback to remove an image */
onRemoveImage?: (id: 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) */
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
modelPresets = [],
selectedModelId,
onModelSelect,
images = [],
onAddImages,
onRemoveImage,
files = [],
onAddFiles,
onRemoveFile,
hosts = [],
permissionMode,
onPermissionModeChange,
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, [value, onChange, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData.items)
.filter((item) => item.type.startsWith('image/'))
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
if (files.length > 0) {
if (pastedFiles.length > 0) {
e.preventDefault();
onAddImages?.(files);
onAddFiles?.(pastedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length > 0) {
onAddImages?.(files);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
onAddFiles?.(droppedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
return (
<div className="shrink-0 px-4 pb-4">
<PromptInput onSubmit={handleSubmit}>
{/* Image attachment chips */}
{images.length > 0 && (
{/* File attachment chips */}
{files.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{images.map((img) => (
{files.map((file) => (
<div
key={img.id}
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"
>
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[80px]">{img.filename}</span>
{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={() => onRemoveImage?.(img.id)}
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} />
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddImages?.(Array.from(e.target.files));
onAddFiles?.(Array.from(e.target.files));
e.target.value = '';
}
}}

View File

@@ -6,10 +6,11 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle } from 'lucide-react';
import React from 'react';
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
ConversationContent,
@@ -28,6 +29,70 @@ interface ChatMessageListProps {
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [zoom, setZoom] = useState(100);
const [dragged, setDragged] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const dragPos = useRef({ x: 0, y: 0 });
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
if (!imgRef.current) return;
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
}, []);
const zoomRef = useRef(100);
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
}, []);
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -10 : 10;
setZoomAndRef(z => {
const nz = Math.max(25, Math.min(200, z + delta));
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
return nz;
});
}, [applyTransform, setZoomAndRef]);
const openPreview = useCallback((src: string, name: string) => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
setPreview({ src, name });
}, []);
const resetPreview = useCallback(() => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
applyTransform(100, 0, 0, true);
}, [applyTransform]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
dragPos.current = { x, y };
applyTransform(zoomRef.current, x, y, false);
}, [applyTransform]);
const endDrag = useCallback(() => {
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
setDragged(true);
}
dragStart.current = null;
}, []);
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const resolvedToolCallIds = new Set(
@@ -36,6 +101,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
.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">
@@ -49,6 +124,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
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) => {
@@ -58,7 +134,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
result={tr.content}
isError={tr.isError}
/>
@@ -83,16 +159,27 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
/>
)}
{/* User images */}
{isUser && message.images && message.images.length > 0 && (
{/* 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.images.map((img, i) => (
<img
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
src={`data:${img.mediaType};base64,${img.base64Data}`}
alt={img.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
/>
{(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 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
/>
) : (
<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>
)}
@@ -139,7 +226,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<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">{message.errorInfo.message}</p>
<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>
)}
@@ -162,6 +251,83 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Image preview lightbox */}
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
<DialogContent
hideCloseButton
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
>
{/* Title bar: filename | zoom controls | close — all in one flex row */}
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={resetPreview}
disabled={zoom === 100 && !dragged}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.reset')}
>
<RotateCcw size={14} />
</button>
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
<button
onClick={zoomOut}
disabled={zoom <= 25}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomOut')}
>
<ZoomOut size={14} />
</button>
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
<button
onClick={zoomIn}
disabled={zoom >= 200}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomIn')}
>
<ZoomIn size={14} />
</button>
</div>
<button
onClick={() => setPreview(null)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
aria-label={t('common.close')}
>
<X size={14} />
</button>
</div>
{/* Image area with drag support */}
{preview && (
<div
className="overflow-hidden flex items-center justify-center"
style={{
height: 'calc(min(90vh, 700px) - 40px)',
cursor: 'grab',
// Clamp aspect ratio: if image is extremely tall/wide, the container
// constrains it; object-contain handles the rest.
aspectRatio: 'auto',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={onWheel}
onLostPointerCapture={endDrag}
>
<img
ref={imgRef}
src={preview.src}
alt={preview.name}
draggable={false}
className="select-none max-w-full max-h-full object-contain"
style={{ transition: 'transform 0.25s ease' }}
/>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -16,6 +16,7 @@ import type {
AIPermissionMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
@@ -27,7 +28,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -135,6 +136,9 @@ export interface PendingApprovalContext {
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
getExecutorContext: () => ExecutorContext;
}
function generateId(): string {
@@ -196,6 +200,7 @@ export interface UseAIChatStreamingReturn {
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
sendToExternalAgent: (
@@ -294,8 +299,6 @@ export function useAIChatStreaming({
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
@@ -591,20 +594,25 @@ export function useAIChatStreaming({
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
},
onToolCall: (toolName: string, args: Record<string, unknown>) => {
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
},
onToolResult: (toolCallId: string, result: string) => {
updateLastMessage(sessionId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
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 }],
@@ -667,16 +675,17 @@ export function useAIChatStreaming({
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const toolContext = context.getExecutorContext ?? (() => ({
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,
toolContext,
getExecutorContext,
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
@@ -740,7 +749,22 @@ export function useAIChatStreaming({
const sdkMessages: Array<ModelMessage> = [];
for (const m of allMessages) {
if (m.role === 'user') {
sdkMessages.push({ role: 'user', content: m.content });
// 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
@@ -779,13 +803,36 @@ export function useAIChatStreaming({
});
}
}
sdkMessages.push({ role: 'user', content: trimmed });
// 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,
sessionId,
scopeKey: sendScopeKey,
sdkMessages,
approvalInfo,
model,
systemPrompt,
tools,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
getExecutorContext,
});
return; // Keep streaming flag — waiting for user approval
}

View File

@@ -22,7 +22,6 @@ import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import type {
ApprovalInfo,
PendingApprovalContext,
TerminalSessionInfo,
} from './useAIChatStreaming';
import { getNetcattyBridge } from './useAIChatStreaming';
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
@@ -75,10 +74,6 @@ export interface UseToolApprovalReturn {
/** Context values needed by handleApprovalResponse that change frequently. */
export interface ToolApprovalContext {
terminalSessions: TerminalSessionInfo[];
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
@@ -146,7 +141,16 @@ export function useToolApproval({
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 } = ctx;
const {
sessionId: sid,
scopeKey: sk,
sdkMessages,
approvalInfo,
model: ctxModel,
scopeType,
scopeLabel,
getExecutorContext,
} = ctx;
// Clear pending approval (and its timeout) via setPendingApproval
setPendingApproval(null);
@@ -218,16 +222,20 @@ export function useToolApproval({
try {
// Rebuild tools and system prompt with the latest permission mode to prevent
// stale closure issues (e.g. user changed permission mode during approval wait)
// stale settings, while keeping the original AI scope pinned to its workspace/session.
const bridge = getNetcattyBridge();
const freshTools = createCattyTools(bridge, {
sessions: approvalContext.terminalSessions,
workspaceId: approvalContext.scopeType === 'workspace' ? approvalContext.scopeTargetId : undefined,
workspaceName: approvalContext.scopeType === 'workspace' ? approvalContext.scopeLabel : undefined,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
const freshExecutorContext = getExecutorContext();
const freshTools = createCattyTools(
bridge,
getExecutorContext,
approvalContext.commandBlocklist,
approvalContext.globalPermissionMode,
approvalContext.webSearchConfig ?? undefined,
);
const freshSystemPrompt = buildSystemPrompt({
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
hosts: approvalContext.terminalSessions.map(s => ({
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,
})),
@@ -246,6 +254,9 @@ export function useToolApproval({
model: ctxModel,
systemPrompt: freshSystemPrompt,
tools: freshTools,
scopeType,
scopeLabel,
getExecutorContext,
});
return;
}

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

@@ -77,7 +77,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
acpCommand: "claude-code-acp",
acpCommand: "claude-agent-acp",
acpArgs: [],
},
};

View File

@@ -64,6 +64,12 @@ type ChainProgressState = {
currentHostLabel: string;
} | null;
export type SessionLogConfig = {
enabled: boolean;
directory: string;
format: string;
};
export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
@@ -76,6 +82,7 @@ export type TerminalSessionStartersContext = {
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionLog?: SessionLogConfig;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;
@@ -456,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,
});
};
@@ -609,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, {
@@ -650,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, {
@@ -708,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;
@@ -787,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

@@ -30,13 +30,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, hideCloseButton, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
const { t } = useI18n()
return (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(

View File

@@ -169,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 {

View File

@@ -197,8 +197,9 @@ export interface SyncPayload {
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
};
// Sync metadata
syncedAt: number; // When this payload was created
}

View File

@@ -36,6 +36,7 @@ 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_CUSTOM_THEMES,
} from '../infrastructure/config/storageKeys';
@@ -153,6 +154,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
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;
}
@@ -211,6 +214,7 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
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));
}
// ---------------------------------------------------------------------------

View File

@@ -21,6 +21,9 @@ module.exports = {
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*',
'node_modules/@zed-industries/claude-agent-acp/**/*',
'node_modules/@agentclientprotocol/sdk/**/*',
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
'node_modules/@zed-industries/codex-acp/**/*',
'node_modules/@zed-industries/codex-acp-*/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',

View File

@@ -141,6 +141,48 @@ async function getShellEnv() {
return _cachedShellEnv;
}
// ── Claude Code ACP binary resolution ──
/**
* Resolve the Claude ACP binary, returning { command, prependArgs }.
*
* On macOS/Linux a shebang-based .js script can be spawned directly, but on
* Windows `child_process.spawn` does not interpret shebangs — so when the
* resolved path is a JS file we invoke it via the system Node runtime.
*/
function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
const binaryName = "claude-agent-acp";
// Dev mode: prefer system PATH (npm creates platform-appropriate wrappers)
const isPackaged = electronModule?.app?.isPackaged;
if (!isPackaged && shellEnv) {
const systemPath = resolveCliFromPath(binaryName, shellEnv);
if (systemPath) return { command: systemPath, prependArgs: [] };
}
// Packaged build (or dev fallback): use npm-bundled binary
try {
const resolved = require.resolve("@zed-industries/claude-agent-acp/dist/index.js");
const scriptPath = toUnpackedAsarPath(resolved);
// On Windows, .js files cannot be spawned directly (no shebang support) —
// invoke via Node. In packaged Electron builds process.execPath is the
// app binary (e.g. Netcatty.exe), not a Node runtime, so we must resolve
// the real `node` from PATH. If Node is not installed, fall back to the
// bare command name and let the system find the npm-generated .cmd wrapper.
if (process.platform === "win32") {
const nodePath = resolveCliFromPath("node", shellEnv);
if (nodePath) {
return { command: nodePath, prependArgs: [scriptPath] };
}
return { command: binaryName, prependArgs: [] };
}
return { command: scriptPath, prependArgs: [] };
} catch {
return { command: binaryName, prependArgs: [] };
}
}
// ── Stream chunk serialization ──
function serializeStreamChunk(chunk) {
@@ -154,13 +196,25 @@ function serializeStreamChunk(chunk) {
return { type: "reasoning-start", id: chunk.id ?? undefined };
case "reasoning-end":
return { type: "reasoning-end", id: chunk.id ?? undefined };
case "tool-call":
case "tool-call": {
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool" —
// the real tool name and args are inside chunk.args
const isAcpWrapper = chunk.toolName === "acp.acp_provider_agent_dynamic_tool";
const acpInput = isAcpWrapper ? chunk.input : null;
let realToolName = isAcpWrapper ? (acpInput?.toolName || chunk.toolName) : chunk.toolName;
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args) : chunk.args;
const realToolCallId = isAcpWrapper ? (acpInput?.toolCallId || chunk.toolCallId) : chunk.toolCallId;
// Simplify MCP tool names: "mcp__netcatty-remote-hosts__get_environment" → "get_environment"
if (realToolName && realToolName.includes("__")) {
realToolName = realToolName.split("__").pop();
}
return {
type: "tool-call",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: chunk.args,
toolCallId: realToolCallId,
toolName: realToolName,
args: realArgs,
};
}
case "tool-result":
return {
type: "tool-result",
@@ -185,6 +239,7 @@ module.exports = {
isLocalhostHostname,
extractFirstNonLocalhostUrl,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
getShellEnv,
serializeStreamChunk,

View File

@@ -18,6 +18,7 @@ const mcpServerBridge = require("./mcpServerBridge.cjs");
const {
stripAnsi,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
serializeStreamChunk,
} = require("./ai/shellUtils.cjs");
@@ -486,6 +487,10 @@ function registerHandlers(ipcMain) {
// Track temporarily added entries so cleanup can distinguish them from synced ones
const tempAllowedHosts = new Set();
const tempAllowedPorts = new Set();
// Track temporarily added HTTP hosts (for rebuild restoration)
const tempHttpHosts = new Set();
// Track active expiry timers per host to avoid duplicate/premature expiry
const hostExpiryTimers = new Map();
/** Check if a host is owned by a currently synced provider config */
function isHostInProviderConfigs(host) {
@@ -495,6 +500,17 @@ function registerHandlers(ipcMain) {
}
return false;
}
/** Check if a host is owned by a provider config that uses http:// */
function isHttpHostInProviderConfigs(host) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
const p = new URL(config.baseURL);
if (p.hostname === host && p.protocol === "http:") return true;
} catch {}
}
return false;
}
/** Check if a localhost port is owned by a currently synced provider config */
function isPortInProviderConfigs(port) {
for (const config of providerConfigs) {
@@ -528,17 +544,36 @@ function registerHandlers(ipcMain) {
}, TEMP_ALLOWLIST_TTL);
}
} else {
if (!providerFetchHosts.has(host)) {
const isNewHost = !providerFetchHosts.has(host);
if (isNewHost) {
providerFetchHosts.add(host);
tempAllowedHosts.add(host);
setTimeout(() => {
// Only remove if not owned by a synced provider config
if (!isHostInProviderConfigs(host)) {
providerFetchHosts.delete(host);
}
tempAllowedHosts.delete(host);
}, TEMP_ALLOWLIST_TTL);
}
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
tempAllowedHosts.add(host);
if (parsed.protocol === "http:") {
providerHttpHosts.add(host);
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
}
// Always (re-)schedule expiry timer to clean up temp entries
const existing = hostExpiryTimers.get(host);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
hostExpiryTimers.delete(host);
// Check if host is still needed by a provider config or web search
const isWebSearchHost = webSearchApiHost && (() => {
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
})();
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
providerFetchHosts.delete(host);
providerHttpHosts.delete(host);
} else if (!isHttpHostInProviderConfigs(host)) {
providerHttpHosts.delete(host);
}
tempAllowedHosts.delete(host);
tempHttpHosts.delete(host);
}, TEMP_ALLOWLIST_TTL);
hostExpiryTimers.set(host, timer);
}
return { ok: true };
} catch {
@@ -560,6 +595,8 @@ function registerHandlers(ipcMain) {
]);
// Dynamically populated from configured provider baseURLs
const providerFetchHosts = new Set();
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
const providerHttpHosts = new Set();
/**
* Rebuild the dynamic host allowlist from the current providerConfigs.
@@ -567,11 +604,13 @@ function registerHandlers(ipcMain) {
*/
function rebuildProviderFetchHosts() {
providerFetchHosts.clear();
providerHttpHosts.clear();
// Reset localhost ports to built-in defaults, then add provider-configured ones
ALLOWED_LOCALHOST_PORTS.clear();
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
// Re-add any still-active temporary entries so a sync doesn't wipe them
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
for (const host of tempHttpHosts) providerHttpHosts.add(host);
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
for (const config of providerConfigs) {
if (!config.baseURL) continue;
@@ -584,6 +623,7 @@ function registerHandlers(ipcMain) {
ALLOWED_LOCALHOST_PORTS.add(port);
} else {
providerFetchHosts.add(host);
if (parsed.protocol === "http:") providerHttpHosts.add(host);
}
} catch {
// Invalid URL in config — skip
@@ -669,16 +709,16 @@ function registerHandlers(ipcMain) {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
return ALLOWED_LOCALHOST_PORTS.has(port);
}
// Require HTTPS for remote hosts; allow HTTP only for the configured web search apiHost
// (e.g. self-hosted SearXNG at http://searxng.lan:8080 or http://192.168.x.x)
if (parsed.protocol !== "https:") {
if (!webSearchApiHost) return false;
try {
const wsHost = new URL(webSearchApiHost).hostname;
if (parsed.hostname !== wsHost) return false;
} catch {
return false;
// Only allow http: and https: schemes for remote hosts
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
if (parsed.protocol === "http:") {
const isProviderHost = providerHttpHosts.has(parsed.hostname);
let isWebSearchHost = false;
if (webSearchApiHost) {
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
}
if (!isProviderHost && !isWebSearchHost) return false;
}
// Check built-in + provider-configured host allowlist
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
@@ -701,16 +741,12 @@ function registerHandlers(ipcMain) {
const resolvedUrl = patched.url;
const resolvedHeaders = patched.headers;
// Validate URL: only allow HTTP(S) schemes; require HTTPS for non-localhost
// Validate URL: only allow HTTP(S) schemes
try {
const parsed = new URL(resolvedUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
}
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol === "http:" && !isLocalhost) {
return { ok: false, error: "HTTP is only allowed for localhost" };
}
} catch {
return { ok: false, error: "Invalid URL" };
}
@@ -1141,7 +1177,7 @@ function registerHandlers(ipcMain) {
}
}
// Discover external agents from PATH, plus the bundled Codex CLI if present.
// Discover external agents from PATH, plus bundled ACP binaries if present.
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const agents = [];
@@ -1151,9 +1187,10 @@ function registerHandlers(ipcMain) {
name: "Claude Code",
icon: "claude",
description: "Anthropic's agentic coding assistant",
acpCommand: "claude-code-acp",
acpCommand: "claude-agent-acp",
acpArgs: [],
args: ["-p", "--output-format", "text", "{prompt}"],
resolveAcp: resolveClaudeAcpBinaryPath,
},
{
command: "codex",
@@ -1163,6 +1200,7 @@ function registerHandlers(ipcMain) {
acpCommand: "codex-acp",
acpArgs: [],
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
];
@@ -1187,20 +1225,54 @@ function registerHandlers(ipcMain) {
resolvedPath = null;
}
// If the base command is not on PATH, check whether the bundled ACP
// binary is available — the agent can still work via ACP without the
// standalone CLI installed.
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
// resolveCodexAcpBinaryPath returns a plain string.
let versionCommand = null;
let versionPrependArgs = [];
if (!resolvedPath && agent.resolveAcp) {
const result = agent.resolveAcp(shellEnv, electronModule);
if (typeof result === "string") {
if (result && result !== agent.acpCommand && existsSync(result)) {
resolvedPath = result;
}
} else if (result?.command) {
// On Windows the command may be `node` with the script in prependArgs.
// Use the script path for display/dedup so the UI shows the actual
// agent rather than the Node binary.
const scriptPath = result.prependArgs?.[0];
const displayPath = scriptPath || result.command;
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
resolvedPath = displayPath;
if (scriptPath) {
versionCommand = result.command;
versionPrependArgs = result.prependArgs;
}
}
}
}
if (!resolvedPath || seenPaths.has(resolvedPath)) {
continue;
}
let version = "";
try {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
const probeCmd = versionCommand || resolvedPath;
const probeArgs = [...versionPrependArgs, "--version"];
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
}
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agent,
...agentInfo,
path: resolvedPath,
version,
available: true,
@@ -1412,7 +1484,7 @@ function registerHandlers(ipcMain) {
// Known agent command names (must match knownAgents in discover handler)
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-code-acp",
"claude", "claude-agent-acp",
"codex", "codex-acp",
]);
@@ -1630,6 +1702,7 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1707,13 +1780,19 @@ function registerHandlers(ipcMain) {
agentEnv.CODEX_API_KEY = apiKey;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: acpCommand;
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const provider = createACPProvider({
command: resolvedCommand,
args: acpArgs || [],
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
@@ -1750,11 +1829,16 @@ function registerHandlers(ipcMain) {
cleanupAcpProvider(chatSessionId);
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const fallbackProvider = createACPProvider({
command: isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: acpCommand,
args: acpArgs || [],
: fallbackClaudeAcp
? fallbackClaudeAcp.command
: acpCommand,
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
session: {
cwd: sessionCwd,
@@ -1802,20 +1886,52 @@ function registerHandlers(ipcMain) {
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
`Do NOT use local shell execution.]\n\n${prompt}`;
// Build message content: text + optional images
function buildMessageContent(text, imgs) {
const content = [{ type: "text", text }];
if (Array.isArray(imgs)) {
for (const img of imgs) {
if (!img.base64Data || !img.mediaType) continue;
// Build message content: text + optional attachments
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
// For other file types (PDF, text, etc.), tell the agent the original file
// path so it can read it directly — ACP agents have local file access.
function buildMessageContent(text, attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) {
return text;
}
const content = [];
const fileHints = [];
for (const att of attachments) {
if (!att.base64Data || !att.mediaType) continue;
if (att.mediaType.startsWith("image/")) {
// Images: pass inline as ACP-compatible file parts
content.push({
type: "file",
mediaType: img.mediaType,
data: img.base64Data,
...(img.filename ? { filename: img.filename } : {}),
mediaType: att.mediaType,
data: att.base64Data,
...(att.filename ? { filename: att.filename } : {}),
});
} else if (att.filePath) {
// Non-image files with a known local path: tell the agent to read it
fileHints.push(`[Attached file "${att.filename || "file"}" is on the LOCAL machine (not a remote server), path: ${att.filePath} — read it locally]`);
} else {
// Pasted/virtual files without a path: save to managed temp dir so the agent can read them
try {
const fs = require("node:fs");
const tempDirBridge = require("./tempDirBridge.cjs");
const safeName = att.filename || `file-${Date.now()}`;
const tempPath = tempDirBridge.getTempFilePath(safeName);
fs.writeFileSync(tempPath, Buffer.from(att.base64Data, "base64"));
fileHints.push(`[Attached file "${att.filename || safeName}" is on the LOCAL machine (not a remote server), path: ${tempPath} — read it locally]`);
} catch (err) {
console.error("[ACP] Failed to save pasted attachment to temp:", err?.message || err);
}
}
}
const fullText = fileHints.length > 0
? fileHints.join("\n") + "\n\n" + text
: text;
content.unshift({ type: "text", text: fullText });
return content;
}

View File

@@ -161,7 +161,17 @@ async function renameLocalFile(event, payload) {
* Create a local directory
*/
async function mkdirLocal(event, payload) {
await fs.promises.mkdir(payload.path, { recursive: true });
try {
await fs.promises.mkdir(payload.path, { recursive: true });
} catch (err) {
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
// If the directory already exists, that's fine — ignore the error.
try {
const stat = await fs.promises.stat(payload.path);
if (stat.isDirectory()) return true;
} catch { /* stat failed, re-throw original */ }
throw err;
}
return true;
}

View File

@@ -0,0 +1,207 @@
/**
* Session Log Stream Manager - Manages real-time log write streams per session
* Writes terminal data to files in real-time instead of only on session close.
* Fixes issue #394 where session logs only capture ~55 lines.
*/
const fs = require("node:fs");
const path = require("node:path");
const { stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
// Active log streams keyed by sessionId
const activeStreams = new Map();
// Buffer flush interval (ms)
const FLUSH_INTERVAL = 500;
// Max buffer size before immediate flush (bytes)
const MAX_BUFFER_SIZE = 64 * 1024;
/**
* Start a log stream for a session.
* Creates the log file and opens a write stream.
* @param {string} sessionId
* @param {{ hostLabel: string, hostname: string, directory: string, format: string, startTime?: number }} opts
*/
function startStream(sessionId, opts) {
if (activeStreams.has(sessionId)) {
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
stopStream(sessionId);
}
const { hostLabel, hostname, directory, format, startTime } = opts;
if (!directory) {
console.warn("[SessionLogStream] No directory specified, skipping");
return;
}
try {
// Build file path: directory / hostSubdir / timestamp.ext
const safeHostLabel = (hostLabel || hostname || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_");
const hostDir = path.join(directory, safeHostLabel);
fs.mkdirSync(hostDir, { recursive: true });
const date = new Date(startTime || Date.now());
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
// For html format, write raw data to a temp file during streaming,
// then convert on stopStream.
const isHtml = format === "html";
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
const fileName = `${dateStr}.${ext}`;
const filePath = path.join(hostDir, fileName);
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// Disable this stream on error to avoid cascading failures
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
const entry = {
writeStream,
filePath,
hostDir,
format,
isHtml,
hostLabel: hostLabel || hostname || "unknown",
startTime: startTime || Date.now(),
buffer: "",
flushTimer: null,
disabled: false,
};
// Start periodic flush
entry.flushTimer = setInterval(() => {
flushBuffer(entry);
}, FLUSH_INTERVAL);
activeStreams.set(sessionId, entry);
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
} catch (err) {
console.error(`[SessionLogStream] Failed to start stream for ${sessionId}:`, err.message);
}
}
/**
* Flush buffered data to the write stream.
* @param {object} entry - The stream entry
*/
function flushBuffer(entry) {
if (!entry || entry.disabled || entry.buffer.length === 0) return;
try {
const data = entry.buffer;
entry.buffer = "";
if (entry.isHtml) {
// For HTML format, write raw data during streaming; convert on close
entry.writeStream.write(data);
} else if (entry.format === "raw") {
entry.writeStream.write(data);
} else {
// txt format: strip ANSI codes
entry.writeStream.write(stripAnsi(data));
}
} catch (err) {
console.error("[SessionLogStream] Flush error:", err.message);
entry.disabled = true;
}
}
/**
* Append data to the session's log buffer.
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
* @param {string} sessionId
* @param {string} dataChunk - Decoded terminal data string
*/
function appendData(sessionId, dataChunk) {
const entry = activeStreams.get(sessionId);
if (!entry || entry.disabled) return;
entry.buffer += dataChunk;
// Immediate flush if buffer is large
if (entry.buffer.length >= MAX_BUFFER_SIZE) {
flushBuffer(entry);
}
}
/**
* Stop the log stream for a session.
* Flushes remaining data, closes the write stream, and finalizes the file.
* @param {string} sessionId
* @returns {Promise<string|null>} The final file path, or null if no stream was active
*/
async function stopStream(sessionId) {
const entry = activeStreams.get(sessionId);
if (!entry) return null;
activeStreams.delete(sessionId);
// Stop periodic flush
if (entry.flushTimer) {
clearInterval(entry.flushTimer);
entry.flushTimer = null;
}
// Flush remaining buffer
flushBuffer(entry);
// Close the write stream and wait for it to finish
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
let finalPath = entry.filePath;
// For HTML format: read the temp raw file and convert to HTML
if (entry.isHtml && !entry.disabled) {
try {
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
// Remove temp file
try {
await fs.promises.unlink(entry.filePath);
} catch {
// Ignore cleanup errors
}
finalPath = htmlPath;
} catch (err) {
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
// Keep the raw temp file as fallback
}
}
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
return finalPath;
}
/**
* Check if a session has an active log stream.
* @param {string} sessionId
* @returns {boolean}
*/
function hasStream(sessionId) {
return activeStreams.has(sessionId);
}
/**
* Cleanup all active streams (called on app quit).
*/
async function cleanupAll() {
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
const ids = [...activeStreams.keys()];
await Promise.allSettled(ids.map(id => stopStream(id)));
}
module.exports = {
startStream,
appendData,
stopStream,
hasStream,
cleanupAll,
};

View File

@@ -22,6 +22,7 @@ const {
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getSshAgentSocket,
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -977,6 +978,17 @@ async function startSSHSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || options.hostname || '',
hostname: options.hostname || '',
directory: options.sessionLog.directory,
format: options.sessionLog.format || 'txt',
startTime: Date.now(),
});
}
// Data buffering for reduced IPC overhead
let dataBuffer = '';
let flushTimeout = null;
@@ -1009,12 +1021,16 @@ async function startSSHSession(event, options) {
stream.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stdout");
bufferData(decoder.write(data));
const decoded = decoder.write(data);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
});
stream.stderr?.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stderr");
bufferData(decoder.write(data));
const decoded = decoder.write(data);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
});
// Capture the real exit code from the remote process.
@@ -1036,6 +1052,7 @@ async function startSSHSession(event, options) {
clearTimeout(flushTimeout);
}
flushBuffer();
sessionLogStreamManager.stopStream(sessionId);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
sessions.delete(sessionId);
@@ -1086,6 +1103,7 @@ async function startSSHSession(event, options) {
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1100,6 +1118,7 @@ async function startSSHSession(event, options) {
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1112,6 +1131,7 @@ async function startSSHSession(event, options) {
conn.once("close", () => {
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);

View File

@@ -11,6 +11,8 @@ const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
// Shared references
let sessions = null;
let electronModule = null;
@@ -213,13 +215,26 @@ function startLocalSession(event, payload) {
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: "Local",
hostname: "localhost",
directory: payload.sessionLog.directory,
format: payload.sessionLog.format || "txt",
startTime: Date.now(),
});
}
proc.onData((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Signal present = killed externally (show disconnected UI).
@@ -390,6 +405,17 @@ async function startTelnetSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || hostname,
hostname,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
resolve({ sessionId });
});
@@ -416,6 +442,7 @@ async function startTelnetSession(event, options) {
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
}
});
@@ -423,10 +450,11 @@ async function startTelnetSession(event, options) {
socket.on('error', (err) => {
console.error(`[Telnet] Socket error: ${err.message}`);
clearTimeout(connectTimeout);
if (!connected) {
reject(new Error(`Failed to connect: ${err.message}`));
} else {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
const contents = electronModule.webContents.fromId(session.webContentsId);
@@ -440,6 +468,7 @@ async function startTelnetSession(event, options) {
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
clearTimeout(connectTimeout);
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
const contents = electronModule.webContents.fromId(session.webContentsId);
@@ -519,12 +548,25 @@ async function startMoshSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || options.hostname,
hostname: options.hostname,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
proc.onData((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Mosh non-zero exit typically means connection/auth failure — show error UI
@@ -607,6 +649,17 @@ async function startSerialSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || portPath,
hostname: portPath,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
const serialDecoder = new StringDecoder('latin1');
serialPort.on('data', (data) => {
@@ -614,11 +667,13 @@ async function startSerialSession(event, options) {
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessions.delete(sessionId);
@@ -626,6 +681,7 @@ async function startSerialSession(event, options) {
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessions.delete(sessionId);

View File

@@ -8,6 +8,24 @@ const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
* On Windows, `mkdir("E:\\", { recursive: true })` throws EPERM for drive roots.
* We catch that and verify the directory already exists before re-throwing.
*/
async function ensureLocalDir(dir) {
try {
await fs.promises.mkdir(dir, { recursive: true });
} catch (err) {
// If the directory already exists, ignore the error (covers EPERM on drive roots)
try {
const stat = await fs.promises.stat(dir);
if (stat.isDirectory()) return;
} catch { /* stat failed, re-throw original */ }
throw err;
}
}
// ── Transfer performance tuning ──────────────────────────────────────────────
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
// dramatically improving throughput over sequential stream piping.
@@ -430,14 +448,14 @@ async function startTransfer(event, payload, onProgress) {
if (!client) throw new Error("Source SFTP session not found");
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await ensureLocalDir(dir);
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await ensureLocalDir(dir);
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });

View File

@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
@@ -849,6 +850,11 @@ if (!gotLock) {
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
sessionLogStreamManager.cleanupAll();
} catch (err) {
console.warn("Error during session log stream cleanup:", err);
}
try {
terminalBridge.cleanupAllSessions();
} catch (err) {

7
global.d.ts vendored
View File

@@ -76,6 +76,8 @@ declare global {
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
sudo?: boolean;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
interface SftpStatResult {
@@ -143,6 +145,7 @@ declare global {
rows?: number;
charset?: string;
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
@@ -155,8 +158,9 @@ declare global {
rows?: number;
charset?: string;
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
@@ -165,6 +169,7 @@ declare global {
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;

View File

@@ -13,8 +13,8 @@ export interface AcpAgentCallbacks {
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
onThinkingDone: () => void;
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
onToolResult: (toolCallId: string, result: string) => void;
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => void;
onToolResult: (toolCallId: string, result: string, toolName?: string) => void;
onStatus?: (message: string) => void;
onError: (error: string) => void;
onDone: () => void;
@@ -50,12 +50,16 @@ interface StreamEvent {
* Sends the prompt to the main process which runs streamText() with the ACP provider.
* Stream events are forwarded back via IPC.
*/
export interface ImageAttachment {
export interface FileAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string;
}
/** @deprecated Use FileAttachment instead */
export type ImageAttachment = FileAttachment;
export async function runAcpAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
@@ -167,16 +171,18 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
const input = (event.input as Record<string, unknown>) || {};
callbacks.onToolCall(toolName, input);
const toolCallId = (event.toolCallId as string) || undefined;
callbacks.onToolCall(toolName, input, toolCallId);
break;
}
case 'tool-result': {
const toolCallId = (event.toolCallId as string) || '';
const toolName = (event.toolName as string) || undefined;
const output = event.output ?? event.result;
const result = typeof output === 'string'
? output
: JSON.stringify(output);
callbacks.onToolResult(toolCallId, result);
callbacks.onToolResult(toolCallId, result, toolName);
break;
}
case 'status': {

View File

@@ -1,47 +1,15 @@
import type { ChatMessage } from './types';
/**
* Classifies a raw error string into structured error info for display.
* Convert a raw error string into display-safe error info.
*
* Intentionally avoids keyword-based "root cause" attribution because upstream
* providers often return generic 4xx/5xx text that would be misclassified.
* We show the sanitized upstream message directly instead.
*/
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
const lower = error.toLowerCase();
// Network errors
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
}
// Timeout
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
}
// Auth errors
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
}
// Rate limit
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
}
// Provider errors (5xx)
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
}
// Model not found
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
}
// Command blocked
if (lower.includes('blocked by safety')) {
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
}
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
return { type: 'unknown', message, retryable: false };
}
const MAX_ERROR_MESSAGE_LENGTH = 500;

View File

@@ -23,17 +23,23 @@ export interface ModelInfo {
}
// Chat types
export interface ChatMessageImage {
export interface ChatMessageAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string; // original filesystem path (for ACP agents to read directly)
}
/** @deprecated Use ChatMessageAttachment instead */
export type ChatMessageImage = ChatMessageAttachment;
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
images?: ChatMessageImage[];
attachments?: ChatMessageAttachment[];
/** @deprecated Use attachments instead. Kept for backward compatibility with persisted sessions. */
images?: ChatMessageAttachment[];
thinking?: string;
thinkingDurationMs?: number;
toolCalls?: ToolCall[];
@@ -144,7 +150,7 @@ export interface ExternalAgentConfig {
env?: Record<string, string>;
icon?: string;
enabled: boolean;
/** ACP command (e.g. 'codex-acp', 'claude-code-acp', 'gemini --experimental-acp') */
/** ACP command (e.g. 'codex-acp', 'claude-agent-acp', 'gemini --experimental-acp') */
acpCommand?: string;
acpArgs?: string[];
}

View File

@@ -51,6 +51,7 @@ export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_clic
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
export const STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR = 'netcatty_sftp_auto_open_sidebar_v1';
// Editor Settings
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';

351
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@zed-industries/claude-agent-acp": "0.22.2",
"@zed-industries/codex-acp": "0.10.0",
"ai": "^6.0.116",
"clsx": "2.1.1",
@@ -194,6 +195,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.76",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.34.2",
"@img/sharp-darwin-x64": "^0.34.2",
"@img/sharp-linux-arm": "^0.34.2",
"@img/sharp-linux-arm64": "^0.34.2",
"@img/sharp-linux-x64": "^0.34.2",
"@img/sharp-linuxmusl-arm64": "^0.34.2",
"@img/sharp-linuxmusl-x64": "^0.34.2",
"@img/sharp-win32-arm64": "^0.34.2",
"@img/sharp-win32-x64": "^0.34.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -2657,6 +2681,310 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -6347,6 +6675,29 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@zed-industries/claude-agent-acp": {
"version": "0.22.2",
"resolved": "https://registry.npmjs.org/@zed-industries/claude-agent-acp/-/claude-agent-acp-0.22.2.tgz",
"integrity": "sha512-GLiKxy5MBNS9UoiE1XaM9EHVxlEcvk0sXSMCnyDp9JNAQliynt0axZrhptTl5AWe6PXGjVh5hMFdPp+yulw2uQ==",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/claude-agent-sdk": "0.2.76",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
}
},
"node_modules/@zed-industries/claude-agent-acp/node_modules/@agentclientprotocol/sdk": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
"integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@zed-industries/codex-acp": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.10.0.tgz",

View File

@@ -55,6 +55,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@zed-industries/claude-agent-acp": "0.22.2",
"@zed-industries/codex-acp": "0.10.0",
"ai": "^6.0.116",
"clsx": "2.1.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB