Compare commits

...

56 Commits

Author SHA1 Message Date
陈大猫
af074c5704 Merge pull request #578 from binaricat/fix/tool-call-duplicate-and-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve tool call duplication and ordering in chat UI
2026-03-30 19:06:49 +08:00
bincxz
c60afdd8fe fix: preserve approval controls for tool calls in non-last assistant messages
When a stream error appends a new assistant message, the previous
one is no longer lastAssistantMessage. Its pending approval tool
calls were rendered as interrupted, losing approve/reject buttons.
Now they retain approval status and controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:56:28 +08:00
bincxz
a1d05ca5b3 fix: resolve tool call duplication and ordering in chat UI
Tool calls were rendered both in the assistant message (as pending)
and in separate tool-result messages (as completed), causing
duplicates. Additionally, new pending tool calls appeared above
completed ones due to message ordering.

Fix: render completed tool calls only from tool-result messages,
and render pending tool calls after all results so they appear
at the bottom in chronological order. Unresolved tool calls from
earlier assistant messages or cancelled sessions are shown inline
as interrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:54:17 +08:00
陈大猫
327ca3806a Merge pull request #577 from tces1/dev
feat: add GitHub Copilot CLI agent support
2026-03-30 18:24:39 +08:00
bincxz
2f71dd3927 revert: don't override copilot acpCommand with resolved path
On Windows the resolved path may be a .cmd shim which spawn()
cannot execute without shell: true. Keep acpCommand as the bare
"copilot" from AGENT_DEFAULTS and let the system resolve it via
PATH at launch time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:16:50 +08:00
bincxz
3844edd49f fix: clean up copilot temp dir even when provider init fails
Move COPILOT_HOME temp dir cleanup before the acpProviders entry
check so it runs even if provider creation failed before the entry
was stored in the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:57:00 +08:00
bincxz
8f97a7e81d fix: use resolved path as copilot acpCommand and add Windows home fallback
- When building managed copilot agent config, set acpCommand to the
  resolved path instead of bare "copilot" so custom paths work for
  ACP launches
- Add USERPROFILE fallback in prepareCopilotHome for Windows where
  HOME may not be set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:48:07 +08:00
bincxz
5daf1f0d6f fix: hoist copilotConfigInfo above try block to fix ReferenceError
copilotConfigInfo was declared with let inside the try block but
referenced in the finally block for temp dir cleanup. Block scoping
caused a ReferenceError that broke list-models for Copilot agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:38:39 +08:00
bincxz
b1a5b92ce4 fix: clean up transient copilot temp dirs and remove verbose MCP logs
- Add COPILOT_HOME cleanup in list-models finally block to prevent
  temp directory accumulation on each model fetch
- Remove verbose console.log in mcpServerBridge dispatch/connect/auth
  that fired on every MCP call for all agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:27:18 +08:00
bincxz
c99a70831a fix: address review issues in copilot agent integration
- Fix matchesManagedAgentConfig acpCommand matching for copilot by
  using a lookup table instead of hardcoded ternary
- Remove dead nodeRuntimePath variable and unused 4th arg to
  buildMcpServerConfig
- Fix model loading useEffect double-triggering by reading
  agentModelMap via ref instead of dependency
- Add temp COPILOT_HOME cleanup in cleanupAcpProvider
- Remove dead acpForceProviderReset Set (never populated after
  stop/resume refactor)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:22:59 +08:00
bincxz
4b0468b0d2 merge: resolve conflicts with main for copilot agent support
Adapt copilot agent additions to the refactored managed agent
architecture (resolveAgentPath + buildManagedAgentState pattern).
Add copilot to ManagedAgentKey type and MANAGED_AGENT_META.
Keep main's resolveMcpServerRuntimeCommand (process.execPath +
ELECTRON_RUN_AS_NODE) over PR's runtimeCommand parameter approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:14:45 +08:00
陈大猫
f32078f270 Merge pull request #575 from binaricat/codex/fix-codex-agent-path-and-mcp-startup
[codex] fix codex agent path detection and MCP startup
2026-03-30 17:02:06 +08:00
Eric Chan
a525c073b9 fix: matchesAgentCommand update for windows shim 2026-03-30 16:29:14 +08:00
bincxz
afceb92a55 fix: fall back to PATH search when stored CLI path is stale
When a previously stored custom path no longer exists (e.g. CLI
reinstalled to a different location), aiResolveCli now falls back
to PATH-based detection instead of returning unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:27:32 +08:00
bincxz
4822894efb refactor: eliminate circular effect dependency in managed agent consolidation
Move agent dedup/consolidation from a useEffect (that depended on
externalAgents while also setting it) into resolveAgentPath, using
setExternalAgents(prev => ...) callback form. Use a ref for
defaultAgentId to avoid dependency cycles and keep it in sync
across concurrent codex+claude resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:08:04 +08:00
Eric Chan
d9b51c3a50 feat: add GitHub Copilot CLI agent support 2026-03-30 15:53:08 +08:00
bincxz
15b1dba558 fix stale managed codex path reuse 2026-03-30 15:51:14 +08:00
bincxz
fd6b3930c1 fix codex managed-agent regressions 2026-03-30 15:26:44 +08:00
bincxz
53cb160a6e fix codex agent path detection and MCP startup 2026-03-30 15:04:06 +08:00
陈大猫
bb590f140d Merge pull request #574 from binaricat/fix/autocomplete-click-outside-dismiss
fix: dismiss autocomplete popup on click outside
2026-03-30 11:25:54 +08:00
bincxz
945992b80e fix: dismiss autocomplete popup on click outside
Closes #572

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:23:51 +08:00
陈大猫
b8de9ce2b6 Merge pull request #571 from binaricat/ui/compact-host-select-panel
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-29 22:34:08 +08:00
bincxz
2c7bce31d4 style: reduce border-radius on distro avatars
sm: rounded-md → rounded (4px), md: rounded-xl → rounded-lg (8px),
SelectHostPanel inline: rounded-lg → rounded-md (6px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:36 +08:00
bincxz
004a5f18de fix: use rounded square distro avatar in port forwarding wizard
Use size="sm" (rounded-md) instead of className override that kept
the rounded-xl from the default md size, which appeared circular.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:03 +08:00
bincxz
731d57d355 fix: add missing TooltipProvider import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:25:36 +08:00
bincxz
8c6ff1a6a4 fix: wrap tooltips with TooltipProvider in SelectHostPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:24:36 +08:00
bincxz
f7630b3574 ui: compact host selection panel with smaller icons and text truncation
- Reduce item padding, gaps, icon sizes, and font sizes for a denser list
- Use rounded square (rounded-lg) avatars instead of circles, remove border
- Add tooltip on host label and connection string for long text overflow
- Shrink section headers and group items to match compact style
- Remove border from selected host items for cleaner look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:23:43 +08:00
陈大猫
76bfe26561 Merge pull request #570 from binaricat/fix/sftp-keyboard-action-repeated-across-tabs
fix: isolate SFTP actions and selection state across panes and tabs
2026-03-29 22:13:47 +08:00
bincxz
7079ea66aa fix sftp cross-pane tab focus selection retention 2026-03-29 21:53:11 +08:00
bincxz
6562351955 fix: scope dialog actions and refine selection clearing
- Add dialogActionScopeId to distinguish SftpView and SftpSidePanel
  dialog actions, preventing cross-instance interference
- Refine selectionScope to clear tree selections per-pane instead of
  using clearAllExcept, avoiding side effects on other SFTP surfaces
- Remove selection clearing from tab switch/move/add handlers; clearing
  now only happens on focus side change and file interaction
- Reset keyboard selection and lastSelectedIndex when selections are
  externally cleared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:44:15 +08:00
bincxz
986fdda008 fix sftp selection clearing across panes and tabs 2026-03-29 21:15:28 +08:00
bincxz
af2dc66113 fix: clear all selections when focus side changes
When the user switches focus between left and right panes, clear all
pane selections. Combined with the per-interaction clearing in
toggleSelection/rangeSelect, this ensures:
- Selecting files clears other panes' selections
- Switching sides clears all selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:15:01 +08:00
bincxz
cca4a3a37e fix: clear other selections on file interaction, not tab switch
Move selection clearing from tab switch and pane focus handlers into
toggleSelection/rangeSelect. This means:
- Switching tabs just to look around preserves all selections
- Actually clicking/selecting files clears other tabs' selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:13:24 +08:00
bincxz
75ec050c31 revert: restore clearSelectionsExcept to clear all tabs except target
Clearing same-side inactive tab selections on tab switch is intentional
UX — stale selections on hidden tabs would be confusing when switching
back. Reverts the "preserve same-side" change from 05c48b3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:04:08 +08:00
bincxz
db604e4c41 fix: localize delete dialog labels and preserve moved tab tree selection
- Add i18n keys for "Host" and "Path" labels in delete confirmation
  dialog (was hardcoded English, broken under zh-CN)
- Pass moved tab ID as extra keepId when clearing tree selections after
  moveTabToOtherSide, since the ref still has pre-move state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:50:18 +08:00
bincxz
05c48b3d28 fix: preserve selections in same-side inactive tabs
clearSelectionsExcept was clearing all tabs including same-side inactive
ones, causing users to lose file selections when switching between tabs
on the same side. Now only the opposite side's selections are cleared.

Also scoped tree selection clearing to only affect opposite-side pane
IDs, preventing mounted but hidden SFTP surfaces from losing state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:39:39 +08:00
bincxz
3bb98c9c27 fix: allow paste between different tabs on the same side
The paste check only compared sourceSide vs focusedSide, treating all
tabs on the same side as "same pane". Now it also compares connectionId
so copying from one tab and pasting to a different tab on the same side
works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:24:11 +08:00
bincxz
7f4dcce3cb fix: don't clear dialog action from inactive panes
Revert the stale action clearing in inactive panes (e9ad65f). When
multiple tabs exist on the same side, the inactive tab's effect could
fire before the active tab's, clearing the action and causing it to
be handled by the wrong pane or not at all.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:19:34 +08:00
bincxz
766451d9bb fix: handle empty selection in tree view container keyboard navigation
The tree view's own onKeyDown handler had the same issue as the global
keyboard shortcuts: pressing ArrowDown with no selection would skip the
first item. Apply the same fix (reset focus to -1 for empty selection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:01:58 +08:00
bincxz
6f5a2181b2 fix: suppress SFTP keyboard shortcuts when a dialog is open
Prevents SFTP shortcuts (Delete, Enter, etc.) from firing while
unrelated dialogs are open, which could cause unintended file
operations from outside the SFTP panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:55:01 +08:00
bincxz
297adbb818 fix: clamp anchor for Shift+Arrow from empty selection
When no files are selected, Shift+Arrow would use anchor=-1 causing
invalid slice ranges. Now anchor is set to 0 when Shift is held, so
range selection starts from the first item correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:47:47 +08:00
bincxz
13eeb2cf6d fix: ArrowDown from cleared selection now lands on first item
When selections are cleared (e.g. by switching panes), pressing
ArrowDown would skip the first item because the keyboard focus
defaulted to index 0 and then moved to 1. Now an empty selection
resets focus to -1 so the first arrow press selects item 0.
Applies to both list and tree views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:38:24 +08:00
bincxz
e9ad65fef6 fix: clear stale dialog actions when target pane is inactive
When a dialog action's targetSide matched but the pane was inactive,
the action was left in the store. If the pane later became active, it
would fire the stale action unexpectedly. Now inactive panes clear the
action to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:25:55 +08:00
bincxz
ddb6b5af1e perf: only re-render selected rows on focus change
The showSelectionHighlight check in SftpFileRow's areEqual was causing
all rows to re-render when switching focus between panes. Now only rows
that are actually selected re-render on highlight changes, avoiding
unnecessary work for large file lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:23:35 +08:00
bincxz
c1171d4c7b fix sftp shift selection upward expansion 2026-03-29 18:19:04 +08:00
bincxz
21daccf6ed fix: enforce cross-pane selection mutual exclusivity and improve delete dialog
- Add clearSelectionsExcept to clear all file/tree selections except the
  target pane, called on focus change, tab switch, tab add, and tab move
- Fix SftpFileRow areEqual to include showSelectionHighlight so highlight
  updates when focus changes between panes
- Improve delete confirmation dialog with host/path context and separate
  single vs multi-delete descriptions
- Fix hover style on selected rows to prevent flicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:05:54 +08:00
bincxz
2eed15b4b2 feat: show host label in SFTP operation dialogs
Display the connection's host label at the top of new folder, new file,
rename, overwrite, and delete confirmation dialogs so users can see
which machine the operation targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:50:37 +08:00
bincxz
de7fdfc4b4 fix: ensure SftpSidePanel panes remain active for keyboard shortcuts
SftpSidePanel doesn't sync with the global activeTabStore, so
useActiveTabId would return the main SftpView's tab id, causing
side panel panes to be treated as inactive. Add forceActive prop
to bypass the activeTabId check for contexts that manage pane
visibility themselves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:36:11 +08:00
bincxz
709ed12259 fix: prevent SFTP keyboard actions from repeating across all tabs (#569)
When multiple SFTP connections were open as tabs on the same side,
keyboard-triggered actions (delete, rename, new folder, new file) were
executed on every mounted tab instead of just the active one. This was
because all hidden SftpPaneView instances shared the same dialog action
handler and React batched their effects before clear() could prevent
duplicates.

- Add isActive parameter to useSftpDialogActionHandler so only the
  active tab responds to keyboard shortcut actions
- Compute real isActive state in SftpPaneView using useActiveTabId
  instead of hardcoding true
- Clear opposite side's file selection on pane focus change to prevent
  cross-pane selection leaking into actions

Closes #569

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:31:12 +08:00
bincxz
0826bbb435 style: use Netcatty logo in OAuth callback page
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
Replace the generic terminal SVG icon with the actual Netcatty brand
logo (blue rounded-rect with terminal + cat tail motif).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:49:25 +08:00
bincxz
ec87eb593e fix: show spinner and connecting text during cloud sync connection
Replace yellow pulsing dot with a spinning Loader2 icon when cloud
provider is in connecting state. Also show "Connecting..." text
instead of "Not connected" during the connection attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:44:03 +08:00
bincxz
ecbd50dde4 fix: use accent color for active tab indicator instead of foreground
The top indicator line on active tabs (sessions, logview, vaults, SFTP)
was hardcoded to foreground color (white), making it always white
regardless of the system accent color setting. Changed all 4 tab
indicator lines to use --top-tabs-accent / --accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:31:20 +08:00
bincxz
4dd7640452 fix: allow auto encoding through same-host fast path
The encoding guard was rejecting "auto" which is the default encoding
for nearly all connections, making same-host optimization never trigger.

Frontend now allows "auto" through. Backend resolves "auto" to the
actual session encoding via resolveEncodingForRequest and only proceeds
with exec cp when the resolved encoding is UTF-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:25:36 +08:00
陈大猫
0b08521e63 perf: optimize same-host SFTP transfer with remote cp command (#564)
* perf: optimize same-host SFTP transfer with remote cp command

When both panels are connected to the same remote host, use SSH exec
`cp -a` instead of downloading to local temp then re-uploading. This
eliminates 2x bandwidth usage and reduces latency for same-host transfers.

Closes #561

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

* perf: optimize same-host directory transfer with single cp -ra command

For same-host directory transfers, use a single `cp -ra` command via SSH
exec instead of recursively walking the directory and copying files one
by one. This makes directory copies nearly instant on the remote server.

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

* fix: use endpoint cache key for same-host detection and guard non-UTF-8 paths

Address two code review issues:

1. Compare per-connection cache keys (hostname+port+protocol+sudo+username)
   instead of just hostId for same-host detection. This prevents false
   positives when the same hostId has different session-time overrides.

2. Restrict exec-based cp paths to UTF-8 compatible encodings only.
   Non-UTF-8 encodings (e.g. gb18030) need encodePathForSession which
   shell exec cannot use — fall back to download+upload for those cases.

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

* fix: directory cp semantics, cancellation, and auto encoding guard

1. Use `cp -ra source/. target/` instead of `cp -ra source target` to
   copy directory contents into target, preserving merge semantics when
   the target directory already exists (avoids extra nesting level).

2. Check cancellation state before and after sameHostCopyDirectory call
   so cancelled transfers don't finalize as completed.

3. Exclude 'auto' from exec-safe encodings since auto can resolve to
   non-UTF-8 (e.g. gb18030) at the session level.

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

* fix: wire cancellation into same-host copy paths

1. Single file cp -a: check transfer.cancelled before and after
   execSshCommand so cancelled transfers don't proceed as success.

2. Directory cp -ra: accept transferId, register in activeTransfers
   so cancelTransfer can flag it, and check cancelled state at each
   async boundary. Cleanup via finally block.

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

* fix: abort remote cp process on transfer cancellation

Add execSshCommandCancellable() that wires the SSH exec stream into
transfer.abort, so cancelTransfer can close the stream and kill the
remote cp process immediately instead of waiting for it to finish.

Used in both single-file (cp -a) and directory (cp -ra) same-host paths.

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

* fix: close exec stream immediately if cancelled before callback fires

Check transfer.cancelled at the start of the exec callback and close
the stream right away, preventing the remote cp from running when
cancellation happened between the exec() call and callback delivery.

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

* fix: fallback to download+upload when remote cp is unavailable

On non-POSIX remotes (e.g. Windows SSH servers) where cp is absent,
same-host optimization now gracefully falls back to the existing
download+upload transfer path instead of failing the transfer.

- Single file: try cp -a first, fall back to temp file on non-zero exit
- Directory: sameHostCopyDirectory returns { success: false } instead of
  throwing, frontend falls back to recursive transferDirectory

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

* perf: cache cp unavailability to avoid repeated exec failures

Track sftpIds where remote cp failed in cpUnavailableSet so subsequent
file transfers in the same session skip the exec attempt and go directly
to download+upload, avoiding per-file exec round-trip overhead on
non-POSIX remotes.

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

* fix: skip transferFile for directories already handled by same-host copy

Add !task.isDirectory guard to the else branch so successful
sameHostCopyDirectory doesn't also trigger a redundant transferFile
call that would duplicate data.

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

* fix: dereference symlinks in same-host copy to match SFTP behavior

Use cp -aL instead of cp -a so symlinks are dereferenced (copied as
file contents), matching the existing SFTP download+upload flow which
always transfers resolved file data.

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

* revert: remove -L flag from same-host cp to avoid recursing symlinked dirs

Revert cp -aL back to cp -a. The -L flag dereferences all symlinks
including symlinked directories, which can unexpectedly recurse into
large unrelated directory trees. Using cp -a preserves symlinks as-is,
which is safer and consistent with how the transfer UI treats symlink
directories as non-recursive entries.

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

* fix: refine cp unavailability caching and remove dead import

1. Only cache sftpId in cpUnavailableSet on exit code 127 (command not
   found). Other failures (permission denied, disk full) are transient
   or path-specific and should not disable cp for the entire session.

2. Check cpUnavailableSet at the top of sameHostCopyDirectory to skip
   exec attempt on known non-POSIX remotes. Also cache 127 exits from
   directory copies.

3. Remove unused execSshCommand import from transferBridge (replaced by
   local execSshCommandCancellable) and revert its export from sftpBridge.

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-29 13:21:58 +08:00
陈大猫
59e768c447 fix: prevent key file path from overflowing panel (#551) (#567)
* fix: prevent key file path from overflowing host details panel

Add min-w-0 to flex containers and flex items displaying key file
paths. Without this, flex items default to min-width: auto which
prevents truncate from working and causes long file paths (e.g.
from the file picker) to blow out the panel width.

Closes #551

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

* fix: add overflow-hidden to AsidePanel to prevent content overflow

The root cause of key file paths overflowing the panel was the
AsidePanel container itself lacking overflow-hidden. Even though
inner elements had min-w-0 and truncate, the absolute-positioned
panel div allowed content to visually escape its bounds.

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

* fix: add overflow-hidden to credentials Card and key path row

Ensure truncation works by adding overflow-hidden at multiple
levels: the Port & Credentials Card container and each key file
path flex row.

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

* fix: use w-0 flex-1 to force key file path truncation

min-w-0 alone is insufficient in nested flex layouts. Setting w-0
with flex-1 forces the element to start at zero width and only grow
to fill available space, guaranteeing truncation works.

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-29 13:17:04 +08:00
陈大猫
6a37b8bbc6 fix: use system browser for OAuth flows (#563) (#565) 2026-03-29 12:43:21 +08:00
51 changed files with 1630 additions and 305 deletions

View File

@@ -699,6 +699,9 @@ const en: Messages = {
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
@@ -1684,6 +1687,17 @@ const en: Messages = {
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',

View File

@@ -507,6 +507,9 @@ const zhCN: Messages = {
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
'sftp.deleteConfirm.host': '主机',
'sftp.deleteConfirm.path': '路径',
'sftp.error.loadFailed': '加载目录失败',
'sftp.error.downloadFailed': '下载失败',
'sftp.error.uploadFailed': '上传失败',
@@ -1691,6 +1694,17 @@ const zhCN: Messages = {
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '通过 ACP over stdio`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',

View File

@@ -28,6 +28,7 @@ interface UseSftpPaneActionsParams {
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
dirCacheTtlMs: number;
}
@@ -78,6 +79,7 @@ export const useSftpPaneActions = ({
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
@@ -465,6 +467,10 @@ export const useSftpPaneActions = ({
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
@@ -475,11 +481,15 @@ export const useSftpPaneActions = ({
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
@@ -489,7 +499,7 @@ export const useSftpPaneActions = ({
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {

View File

@@ -14,6 +14,7 @@ interface SftpTabsState {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
@@ -34,6 +35,8 @@ interface SftpTabsState {
getActiveTabId: (side: "left" | "right") => string | null;
}
const EMPTY_SELECTION = new Set<string>();
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
[updateTab],
);
const clearSelectionsExcept = useCallback(
(target: { side: "left" | "right"; tabId: string } | null) => {
const clearSideSelections = (
prev: SftpSideTabs,
side: "left" | "right",
): SftpSideTabs => {
let changed = false;
const tabs = prev.tabs.map((tab) => {
const shouldKeepSelection =
target?.side === side && target.tabId === tab.id;
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
return tab;
}
changed = true;
return { ...tab, selectedFiles: EMPTY_SELECTION };
});
return changed ? { ...prev, tabs } : prev;
};
setLeftTabs((prev) => clearSideSelections(prev, "left"));
setRightTabs((prev) => clearSideSelections(prev, "right"));
},
[],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,

View File

@@ -22,6 +22,7 @@ interface UseSftpTransfersParams {
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
clearCacheForConnection: (connectionId: string) => void;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
@@ -78,6 +79,7 @@ export const useSftpTransfers = ({
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -209,6 +211,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
@@ -228,6 +231,7 @@ export const useSftpTransfers = ({
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
sameHost: sameHost || undefined,
};
let lastProgressUpdate = 0;
@@ -343,6 +347,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
) => {
@@ -433,6 +438,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
isSymlink ? symlinkDepth + 1 : symlinkDepth,
followSymlinks,
);
@@ -496,6 +502,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
@@ -571,6 +578,22 @@ export const useSftpTransfers = ({
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
// Detect same-host: both sides connected to the same remote endpoint.
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
// just hostId, because the same hostId can have different session-time overrides.
const sourceCacheKey = sourcePane.connection?.id
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
: undefined;
const targetCacheKey = targetPane.connection?.id
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
: undefined;
const sameHost = !!(
sourceSftpId && targetSftpId &&
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal &&
sourceCacheKey && targetCacheKey &&
sourceCacheKey === targetCacheKey
);
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
const sourceSide = targetSide === "left" ? "right" : "left";
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
@@ -718,7 +741,34 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.isDirectory) {
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
const encodingSafeForExec =
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
// Try same-host directory optimization first; falls back to recursive transfer
// if remote cp is unavailable (e.g. Windows SSH servers).
let dirHandledBySameHost = false;
if (task.isDirectory && sameHost && encodingSafeForExec && sourceSftpId) {
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const result = await netcattyBridge.require().sameHostCopyDirectory!(
sourceSftpId,
task.sourcePath,
task.targetPath,
sourceEncoding,
task.id,
);
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
dirHandledBySameHost = result.success;
}
if (task.isDirectory && !dirHandledBySameHost) {
// For directory transfers, parent task uses:
// totalBytes = total file count (discovered async)
// transferredBytes = completed file count (incremented by child completions)
@@ -746,12 +796,13 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
if (dirErrors > 0) {
dirPartialFailure = true;
}
} else {
} else if (!task.isDirectory) {
await transferFile(
task,
sourceSftpId,
@@ -761,6 +812,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
}
@@ -1247,6 +1299,7 @@ export const useSftpTransfers = ({
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);

View File

@@ -81,6 +81,7 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -372,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,

View File

@@ -57,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
@@ -235,6 +236,7 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs: DIR_CACHE_TTL_MS,
});
@@ -289,6 +291,7 @@ export const useSftpState = (
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -338,6 +341,7 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
@@ -391,6 +395,7 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
@@ -447,6 +452,8 @@ export const useSftpState = (
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
methodsRef.current.clearSelectionsExcept(...args),
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>

View File

@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AgentModelPreset,
AIPermissionMode,
AISession,
AISessionScope,
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
@@ -425,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
// Ref to read agentModelMap inside the effect without re-triggering it
// when setAgentModel updates the map (avoids double ACP spawn).
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
undefined,
undefined,
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
);
// Per-agent model: recall last selection or use first preset as default
@@ -593,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});

View File

@@ -102,11 +102,14 @@ interface StatusDotProps {
}
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-muted-foreground/50',
};
@@ -279,6 +282,7 @@ interface ProviderCardProps {
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
}
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
}) => {
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
{error}
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
<CloudOff size={14} />
</Button>
</>
) : isConnecting && onCancelConnect ? (
<Button
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1"
>
<X size={14} />
{t('common.cancel')}
</Button>
) : (
<Button
size="sm"
@@ -1088,6 +1105,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
@@ -1104,6 +1122,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>

View File

@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
// Size variants - all use rounded corners for consistency
const sizeClasses = {
sm: "h-6 w-6 rounded-md",
md: "h-11 w-11 rounded-xl",
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
lg: "h-14 w-14 rounded-xl",
};
const iconSizes = {
@@ -98,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
<div
className={cn(
containerClass,
"flex items-center justify-center border border-border/40 overflow-hidden",
"flex items-center justify-center overflow-hidden",
bg,
className,
)}

View File

@@ -739,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
@@ -984,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
@@ -1179,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}

View File

@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
interface SelectHostPanelProps {
hosts: Host[];
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}, [currentPath]);
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
)}
{groupsWithCounts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<LayoutGrid size={18} />
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: group.count })}
</div>
</div>
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Hosts Section */}
{filteredHosts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIds.includes(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-muted border border-border"
? "bg-muted"
: "hover:bg-muted/70",
)}
onClick={() => onSelect(host)}
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-10 w-10"
className="h-8 w-8 rounded-md"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.username}@{host.hostname}:{host.port || 22}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">
{host.label}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">
{connectionStr}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected && (
<Check size={16} className="text-primary" />
<Check size={14} className="text-primary shrink-0" />
)}
</div>
);
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
/>
)}
</div>
</TooltipProvider>
);
};

View File

@@ -33,6 +33,7 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
@@ -130,12 +131,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: isVisible && hasPaneFocus,
});
@@ -149,10 +152,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, []);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
}, []);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
@@ -161,19 +173,30 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible]);
}, [isVisible, syncFocusedSelection]);
useEffect(() => {
if (!isVisible) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
@@ -181,7 +204,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible]);
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
@@ -599,10 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>

View File

@@ -40,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
// Wrapper component that subscribes to activeTabId for CSS visibility
// This isolates the activeTabId subscription - only this component re-renders on tab switch
@@ -79,6 +81,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const { t } = useI18n();
const isActive = useIsSftpActive();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
@@ -132,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive,
});
@@ -139,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const focusedSide = useSftpFocusedSide();
// Handle pane focus when clicking on a pane container
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
// Clear the opposite side's selection so file operations only affect the focused pane
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
const prevSide = sftpFocusStore.getFocusedSide();
sftpFocusStore.setFocusedSide(side);
if (prevSide !== side) {
if (targetTabId) {
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
} else {
// Focus side changed — clear other panes but keep the newly focused pane intact.
keepOnlyActivePaneSelections(sftpRef.current, side);
}
}
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
@@ -255,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
handlePaneFocus("left", tabId);
}, [handleAddTabLeft, handlePaneFocus]);
const handleAddTabRightWithFocus = useCallback(() => {
const tabId = handleAddTabRight();
handlePaneFocus("right", tabId);
}, [handleAddTabRight, handlePaneFocus]);
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
handleSelectTabLeft(tabId);
handlePaneFocus("left", tabId);
}, [handlePaneFocus, handleSelectTabLeft]);
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
handleSelectTabRight(tabId);
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
return (
<SftpContextProvider
hosts={hosts}
@@ -295,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={leftTabsInfo}
side="left"
onSelectTab={handleSelectTabLeft}
onSelectTab={handleSelectTabLeftWithFocus}
onCloseTab={handleCloseTabLeft}
onAddTab={handleAddTabLeft}
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
/>
@@ -313,6 +347,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
@@ -354,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={rightTabsInfo}
side="right"
onSelectTab={handleSelectTabRight}
onSelectTab={handleSelectTabRightWithFocus}
onCloseTab={handleCloseTabRight}
onAddTab={handleAddTabRight}
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
/>
@@ -372,6 +407,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader

View File

@@ -1963,6 +1963,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)

View File

@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP

View File

@@ -11,6 +11,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'openai'
| 'claude'
| 'anthropic'
@@ -20,7 +21,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'terminal'
| 'plus';
type AgentIconVisual = {
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const iconKey = getAgentIconKey(agent);
const visual = AGENT_ICON_VISUALS[iconKey];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'

View File

@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
isSettingsManagedDiscoveredAgent,
matchesManagedAgentConfig,
} from '../../infrastructure/ai/managedAgents';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
(da) => {
if (isSettingsManagedDiscoveredAgent(da)) {
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
}
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
},
),
[discoveredAgents, externalAgents],
);

View File

@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => {
{/* Pending tool calls from the *last* assistant message are rendered
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))

View File

@@ -112,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpListModels?: (
acpCommand: string,
acpArgs?: string[],
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}

View File

@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>

View File

@@ -8,7 +8,7 @@
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
@@ -16,8 +16,12 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
}
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
if (!bridge?.aiResolveCli) return null;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
setResolving(true);
try {
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
}, [setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
isResolvingPath={isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
isResolvingPath={isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- GitHub Copilot CLI Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
</div>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
customPath={copilotCustomPath}
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
className="w-64"
/>
</SettingRow>
</div>

View File

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

View File

@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
"object-contain",
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>

View File

@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
acpCommand: "claude-agent-acp",
acpArgs: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -108,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -124,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

View File

@@ -21,6 +21,7 @@ export interface SftpTransferSource {
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;

View File

@@ -76,8 +76,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onClick={handleSelect}
onDoubleClick={handleOpen}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm hover:bg-accent/50",
isSelectionVisible && "bg-accent text-accent-foreground",
"px-4 py-2 items-center cursor-pointer text-sm",
isSelectionVisible
? "bg-accent text-accent-foreground hover:bg-accent"
: "hover:bg-accent/50",
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
)}
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
@@ -130,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.index !== next.index) return false;
if (prev.isSelected !== next.isSelected) return false;
// Only re-render for showSelectionHighlight changes when the row is actually selected
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.columnWidths.name !== next.columnWidths.name) return false;
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;

View File

@@ -11,11 +11,14 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./index";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
hostLabel?: string;
currentPath?: string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
onDisconnect: () => void;
}
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
label ? (
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
) : null;
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
hostLabel,
currentPath,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
setHostSearch,
onConnect,
onDisconnect,
}) => (
}) => {
const isSingleDeleteTarget = deleteTargets.length === 1;
const deletePath = (() => {
if (isSingleDeleteTarget) {
return deleteTargets[0];
}
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) return uniquePaths[0];
if (uniquePaths.length > 1) return "Multiple locations";
return currentPath;
})();
const showDeleteList = deleteTargets.length > 1;
const deleteListItems = (() => {
if (!showDeleteList) return [];
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) {
return deleteTargets.map((target) => getFileName(target) || target);
}
return deleteTargets;
})();
return (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription>
{t("sftp.deleteConfirm.desc")}
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
</DialogDescription>
</DialogHeader>
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteTargets.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
<div className="space-y-3">
{hostLabel || deletePath ? (
<div className="text-xs text-muted-foreground space-y-1.5">
{hostLabel ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
<span className="break-all">{hostLabel}</span>
</div>
) : null}
{deletePath ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
<span className="break-all">{deletePath}</span>
</div>
) : null}
</div>
))}
) : null}
{showDeleteList ? (
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteListItems.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}
/>
</>
);
);
};

View File

@@ -44,7 +44,7 @@ import { getParentPath, joinPath } from '../../application/state/sftp/utils';
import { buildSftpColumnTemplate, filterHiddenFiles, formatBytes, formatDate, getFileIcon, isNavigableDirectory, sortSftpEntries, type ColumnWidths, type SortField, type SortOrder } from './utils';
import type { SftpTransferSource } from './SftpContext';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
import { sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
@@ -55,6 +55,7 @@ type NodeDescriptor =
interface SftpPaneTreeViewProps {
pane: SftpPane;
side: 'left' | 'right';
onPrepareSelection: () => void;
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onNavigateUp: () => void;
@@ -126,8 +127,10 @@ const TreeNode = React.memo<TreeNodeProps>(({
return (
<div
className={cn(
'grid items-center gap-x-1 px-2 cursor-pointer select-none hover:bg-accent/50 text-sm',
isSelected && 'bg-accent text-accent-foreground',
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
isSelected
? 'bg-accent text-accent-foreground hover:bg-accent'
: 'hover:bg-accent/50',
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
@@ -257,6 +260,7 @@ interface ContextTarget {
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
pane,
side,
onPrepareSelection,
onLoadChildren,
onMoveEntriesToPath,
onNavigateUp,
@@ -368,12 +372,21 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const [rootEntries, setRootEntries] = useState<SftpFileEntry[]>(pane.files ?? []);
const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? '');
useEffect(() => {
if (selectedPaths.size === 0) {
lastClickedPathRef.current = null;
sftpKeyboardSelectionStore.clear(pane.id);
}
}, [pane.id, selectedPaths.size]);
const onOpenEntryRef = useRef(onOpenEntry);
onOpenEntryRef.current = onOpenEntry;
const onNavigateUpRef = useRef(onNavigateUp);
onNavigateUpRef.current = onNavigateUp;
const onNavigateToRef = useRef(onNavigateTo);
onNavigateToRef.current = onNavigateTo;
const onPrepareSelectionRef = useRef(onPrepareSelection);
onPrepareSelectionRef.current = onPrepareSelection;
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
const onDragStartRef = useRef(onDragStart);
@@ -508,6 +521,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
invalidateTreeCache();
dispatchTreePaths({ type: 'RESET' });
sftpTreeSelectionStore.clearSelection(pane.id);
sftpKeyboardSelectionStore.clear(pane.id);
lastClickedPathRef.current = null;
}
}, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]);
@@ -556,11 +570,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
focusTreeContainer();
const state = treeSelectionStateRef.current;
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
const nextSelection: string[] = (() => {
if (e.shiftKey && lastClickedPathRef.current) {
const items = state.visibleItems;
const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1;
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
if (lastIdx !== -1 && currentIdx !== -1) {
const parentPath = getParentPath(entryPath);
const start = Math.min(lastIdx, currentIdx);
@@ -582,7 +596,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
return [entryPath];
})();
onPrepareSelectionRef.current();
sftpTreeSelectionStore.setSelection(pane.id, nextSelection);
if (currentIdx !== -1) {
if (e.shiftKey && lastClickedPathRef.current) {
const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx);
} else {
sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx);
}
}
lastClickedPathRef.current = entryPath;
}, [focusTreeContainer, pane.id]);
@@ -610,23 +633,33 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const delta = e.key === 'ArrowDown' ? 1 : -1;
const currentSelected = [...selectedPathsRef.current];
let currentIdx = -1;
if (currentSelected.length === 1) {
currentIdx = state.visibleIndexByPath.get(currentSelected[0]) ?? -1;
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !state.selectedPaths.has(focusPath)) {
focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = currentIdx + delta;
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
onPrepareSelectionRef.current();
if (e.shiftKey && currentSelected.length > 0) {
const anchorIdx = currentIdx >= 0 ? currentIdx : 0;
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map((item) => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
lastClickedPathRef.current = items[nextIdx].path;

View File

@@ -67,25 +67,31 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
dialogActionScopeId: string;
isPaneFocused: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
forceActive?: boolean;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
dialogActionScopeId,
isPaneFocused,
sftpDefaultViewMode,
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
forceActive,
}) => {
const isActive = true;
const activeTabId = useActiveTabId(side);
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
@@ -354,7 +360,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
],
);
useSftpDialogActionHandler(side, dialogActionHandlers);
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
@@ -495,6 +501,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneTreeView
pane={pane}
side={side}
onPrepareSelection={callbacks.onPrepareSelection}
onLoadChildren={callbacks.onListDirectory}
onMoveEntriesToPath={handleMoveEntriesToPath}
onNavigateUp={callbacks.onNavigateUp}
@@ -573,6 +580,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
currentPath={pane.connection?.currentPath}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
@@ -621,6 +630,7 @@ const sftpPaneViewAreEqual = (
): boolean => {
if (prev.pane !== next.pane) return false;
if (prev.side !== next.side) return false;
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
if (prev.isPaneFocused !== next.isPaneFocused) return false;
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;

View File

@@ -214,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
[onCloseTab],
);
const handleSelectTabClick = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onSelectTab(tabId);
},
[onSelectTab],
);
const handleAddTabClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddTab();
},
[onAddTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
@@ -302,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
<div
key={tab.id}
data-tab-id={tab.id}
onClick={() => onSelectTab(tab.id)}
onClick={(e) => handleSelectTabClick(e, tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
@@ -379,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={onAddTab}
onClick={handleAddTabClick}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -0,0 +1,37 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
tabId: string;
}
export const keepOnlyPaneSelections = (
sftp: SftpStateApi,
target: SftpSelectionTarget | null,
) => {
sftp.clearSelectionsExcept(target);
const paneIds = [
...sftp.leftTabs.tabs.map((tab) => tab.id),
...sftp.rightTabs.tabs.map((tab) => tab.id),
];
for (const paneId of paneIds) {
if (target?.tabId === paneId) continue;
sftpTreeSelectionStore.clearSelection(paneId);
}
};
export const keepOnlyActivePaneSelections = (
sftp: SftpStateApi,
side: "left" | "right",
): SftpSelectionTarget | null => {
const tabId = sftp.getActiveTabId(side);
if (!tabId) {
keepOnlyPaneSelections(sftp, null);
return null;
}
const target = { side, tabId } as const;
keepOnlyPaneSelections(sftp, target);
return target;
};

View File

@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
}
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side) return;
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, handlers]);
}, [action, side, scopeId, handlers, isActive]);
};

View File

@@ -14,6 +14,7 @@ import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import type { SftpFileEntry } from "../../../types";
@@ -72,13 +73,15 @@ export const sftpTreeEnterStore = {
// indices per pane so Shift+Arrow extends correctly.
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
function getKbSelection(paneId: string) {
return _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 };
}
function setKbSelection(paneId: string, anchor: number, focus: number) {
_kbSelectionState.set(paneId, { anchor, focus });
}
export const sftpKeyboardSelectionStore = {
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
set: (paneId: string, anchor: number, focus: number) => {
_kbSelectionState.set(paneId, { anchor, focus });
},
clear: (paneId: string) => {
_kbSelectionState.delete(paneId);
},
};
// Basic navigation keys that work even when custom hotkeys are disabled.
const BASIC_NAV_KEYS: Record<string, string> = {
@@ -90,6 +93,7 @@ interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
dialogActionScopeId: string;
isActive: boolean;
}
@@ -115,6 +119,7 @@ export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
@@ -134,6 +139,12 @@ export const useSftpKeyboardShortcuts = ({
return;
}
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
if (document.querySelector('[role="dialog"][data-state="open"]')) {
return;
}
// ── Arrow Up/Down: move selection ────────────────────────────────
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
const sftp = sftpRef.current;
@@ -155,29 +166,35 @@ export const useSftpKeyboardShortcuts = ({
// Resolve current focus position from tracked state, falling back
// to the actual selection when out of sync (e.g. after mouse click).
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
const currentSelected = Array.from(pane.selectedFiles) as string[];
// If the tracked focus doesn't match the actual selection, re-sync
if (currentSelected.length >= 1 && !currentSelected.includes(listItems[focusIdx])) {
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else if (!currentSelected.includes(listItems[focusIdx])) {
// Tracked focus doesn't match actual selection, re-sync
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
if (focusIdx < 0) focusIdx = 0;
anchorIdx = focusIdx;
setKbSelection(pane.id, anchorIdx, focusIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
// Shift+Arrow: extend range from anchor to new focus
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
setKbSelection(pane.id, anchorIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
setKbSelection(pane.id, nextIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
@@ -191,26 +208,35 @@ export const useSftpKeyboardShortcuts = ({
const currentSelected = [...treeState.selectedPaths];
// Use tracked state, re-sync if needed
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
if (currentSelected.length >= 1 && items[focusIdx]?.path !== currentSelected[currentSelected.length - 1]) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
setKbSelection(pane.id, anchorIdx, focusIdx);
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map(item => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
setKbSelection(pane.id, anchorIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
setKbSelection(pane.id, nextIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
@@ -350,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
if (!clipboard || clipboard.files.length === 0) return;
// Use startTransfer to paste files from source to current pane
// The transfer direction is determined by clipboard sourceSide and current focusedSide
if (clipboard.sourceSide !== focusedSide) {
// Allow paste when source and target are different connections, even on the same side
const isSameConnection = clipboard.sourceSide === focusedSide
&& clipboard.sourceConnectionId === pane.connection.id;
if (!isSameConnection) {
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
@@ -439,6 +467,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpSelectAll": {
if (treeSelectionState.visibleItems.length > 0) {
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftpTreeSelectionStore.selectAllVisible(pane.id);
break;
}
@@ -458,33 +487,38 @@ export const useSftpKeyboardShortcuts = ({
const allFileNames = visibleFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftp.rangeSelect(focusedSide, allFileNames);
break;
}
case "sftpRename": {
if (treeActionSelection.length === 1) {
sftpDialogActionStore.trigger("rename", [treeActionSelection[0].path]);
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
break;
}
// Trigger rename for the first selected file
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length !== 1) return;
sftpDialogActionStore.trigger("rename", selectedFiles);
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
break;
}
case "sftpDelete": {
if (treeActionSelection.length > 0) {
sftpDialogActionStore.trigger("delete", treeActionSelection.map((entry) => entry.path));
sftpDialogActionStore.trigger(
"delete",
dialogActionScopeId,
treeActionSelection.map((entry) => entry.path),
);
break;
}
// Delete selected files
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
sftpDialogActionStore.trigger("delete", selectedFiles);
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
break;
}
@@ -496,7 +530,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder");
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
break;
}
@@ -559,7 +593,7 @@ export const useSftpKeyboardShortcuts = ({
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef]
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../index";
@@ -67,6 +67,12 @@ export const useSftpPaneDragAndSelect = ({
const onUploadRef = useRef(onUploadExternalFiles);
onUploadRef.current = onUploadExternalFiles;
useEffect(() => {
if (pane.selectedFiles.size === 0) {
lastSelectedIndexRef.current = null;
}
}, [pane.selectedFiles.size]);
const getSamePaneDragPaths = useCallback((): string[] | null => {
const dragged = draggedFilesRef.current;
if (!dragged || dragged.length === 0) return null;

View File

@@ -99,6 +99,17 @@ export const sftpTreeSelectionStore = {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -14,6 +15,8 @@ interface UseSftpViewPaneActionsResult {
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
@@ -126,6 +129,12 @@ export const useSftpViewPaneActions = ({
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
const onPrepareSelectionLeft = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "left");
}, [sftpRef]);
const onPrepareSelectionRight = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "right");
}, [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
@@ -151,20 +160,32 @@ export const useSftpViewPaneActions = ({
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionLeft();
sftpRef.current.toggleSelection("left", name, multi);
},
[onPrepareSelectionLeft, sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionRight();
sftpRef.current.toggleSelection("right", name, multi);
},
[onPrepareSelectionRight, sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionLeft();
sftpRef.current.rangeSelect("left", fileNames);
},
[onPrepareSelectionLeft, sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionRight();
sftpRef.current.rangeSelect("right", fileNames);
},
[onPrepareSelectionRight, sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
@@ -266,6 +287,8 @@ export const useSftpViewPaneActions = ({
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,

View File

@@ -140,6 +140,7 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
@@ -176,6 +177,7 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,

View File

@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => void;
handleAddTabRight: () => void;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleSelectTabLeft: (tabId: string) => void;
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
const [hostSearchRight, setHostSearchRight] = useState("");
const handleAddTabLeft = useCallback(() => {
sftpRef.current.addTab("left");
const tabId = sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
return tabId;
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
sftpRef.current.addTab("right");
const tabId = sftpRef.current.addTab("right");
setShowHostPickerRight(true);
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {

View File

@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
/** Called when user clicks outside the popup to dismiss it */
onDismiss?: () => void;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
};
}, [containerRef, onRequestReposition, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
if (!visible || !onDismiss) return;
const handlePointerDown = (e: PointerEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
onDismiss();
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [visible, onDismiss]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${clampedLeft}px`,

View File

@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>

View File

@@ -7,9 +7,11 @@
const https = require("node:https");
const http = require("node:http");
const path = require("node:path");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const fs = require("node:fs");
const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -60,7 +62,6 @@ const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
}
function cleanupAcpProvider(chatSessionId) {
// Clean up temporary COPILOT_HOME directory regardless of whether a
// provider entry exists — prepareCopilotHome may have succeeded before
// provider creation failed.
try {
const tempDirBridge = require("./tempDirBridge.cjs");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
if (existsSync(tempCopilotHome)) {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
}
} catch {
// Best-effort cleanup
}
const entry = acpProviders.get(chatSessionId);
if (!entry) return;
const rootPid = entry.provider?.model?.agentProcess?.pid;
cleanupAcpProviderInstance(entry.provider, chatSessionId);
acpProviders.delete(chatSessionId);
}
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
if (!provider) return;
const rootPid = provider?.model?.agentProcess?.pid;
const childPids = getChildProcessTreePids(rootPid);
try {
if (typeof entry.provider.forceCleanup === "function") {
entry.provider.forceCleanup();
} else if (typeof entry.provider.cleanup === "function") {
entry.provider.cleanup();
if (typeof provider.forceCleanup === "function") {
provider.forceCleanup();
} else if (typeof provider.cleanup === "function") {
provider.cleanup();
}
} catch (err) {
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
}
killTrackedProcessTree(rootPid, childPids);
acpProviders.delete(chatSessionId);
}
function isActiveAcpRun(chatSessionId, requestId) {
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
return Boolean(activeRun && activeRun.requestId === requestId);
}
function isUnsupportedLoadSessionError(err) {
function shouldRetryFreshSession(err) {
const message = String(err?.message || err || "").toLowerCase();
return message.includes("method not found") && message.includes("session/load");
return (message.includes("method not found") && message.includes("session/load"))
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
}
function getChildProcessTreePids(rootPid) {
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
}
}
function summarizeMcpServersForDebug(mcpServers) {
if (!Array.isArray(mcpServers)) return [];
return mcpServers.map((server) => ({
name: server?.name || "",
type: server?.type || "",
command: server?.command || "",
args: Array.isArray(server?.args) ? server.args : [],
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
url: server?.url || "",
}));
}
function logAcpDebug(agentLabel, message, details) {
const prefix = `[ACP DEBUG][${agentLabel}]`;
if (details === undefined) {
console.log(prefix, message);
return;
}
try {
console.log(prefix, message, JSON.stringify(details));
} catch {
console.log(prefix, message, details);
}
}
function normalizeAgentCommandName(command) {
if (typeof command !== "string" || !command) return "";
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
}
function matchesAgentCommand(command, expectedName) {
if (typeof command !== "string" || typeof expectedName !== "string") return false;
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
}
function envPairsToObject(entries) {
if (!Array.isArray(entries)) return {};
const result = {};
for (const entry of entries) {
if (!entry || typeof entry.name !== "string") continue;
result[entry.name] = entry.value == null ? "" : String(entry.value);
}
return result;
}
function mapMcpServerToCopilotConfig(server) {
if (!server || typeof server !== "object" || !server.name) return null;
if (server.type === "stdio" || server.type === "local") {
return {
type: "local",
command: server.command || "",
args: Array.isArray(server.args) ? server.args : [],
env: envPairsToObject(server.env),
tools: ["*"],
};
}
if (server.type === "http" || server.type === "sse") {
return {
type: server.type,
url: server.url || "",
headers: envPairsToObject(server.headers),
tools: ["*"],
};
}
return null;
}
function safeReadJson(filePath) {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
const tempDirBridge = require("./tempDirBridge.cjs");
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
try {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
} catch {
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
}
fs.mkdirSync(tempCopilotHome, { recursive: true });
if (realCopilotHome && existsSync(realCopilotHome)) {
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
}
const configPath = path.join(tempCopilotHome, "mcp-config.json");
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
const mergedServers = { ...(baseConfig.mcpServers || {}) };
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
const mapped = mapMcpServerToCopilotConfig(server);
if (!mapped) continue;
mergedServers[server.name] = mapped;
}
fs.writeFileSync(
configPath,
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
{ mode: 0o600 },
);
return {
copilotHome: tempCopilotHome,
configPath,
serverNames: Object.keys(mergedServers),
};
}
/**
* Make a streaming HTTP request and forward SSE events back to renderer
*/
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
{
command: "copilot",
name: "GitHub Copilot CLI",
icon: "copilot",
description: "GitHub's coding agent CLI",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
args: ["-p", "{prompt}"],
},
];
const shellEnv = await getShellEnv();
@@ -1309,6 +1459,7 @@ function registerHandlers(ipcMain) {
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
available: true,
@@ -1327,7 +1478,9 @@ function registerHandlers(ipcMain) {
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
resolvedPath = normalizeCliPathForPlatform(customPath);
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
@@ -1521,6 +1674,7 @@ function registerHandlers(ipcMain) {
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-agent-acp",
"codex", "codex-acp",
"copilot",
]);
// Spawn an external agent process
@@ -1730,6 +1884,102 @@ function registerHandlers(ipcMain) {
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let provider = null;
let copilotConfigInfo = null;
try {
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
const agentEnv = { ...shellEnv };
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
provider = createACPProvider({
command: resolvedCommand,
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
}
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
} catch {
// Ignore cleanup failures for transient model-discovery providers.
}
// Clean up transient COPILOT_HOME created for model listing
if (copilotConfigInfo?.copilotHome) {
try {
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
} catch { /* best-effort */ }
}
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
@@ -1771,8 +2021,10 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1811,6 +2063,13 @@ function registerHandlers(ipcMain) {
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
chatSessionId,
scopedIds,
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
});
}
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
@@ -1821,9 +2080,7 @@ function registerHandlers(ipcMain) {
const currentPermissionMode = mcpServerBridge.getPermissionMode();
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
!shouldForceProviderReset &&
providerEntry &&
providerEntry.acpCommand === acpCommand &&
providerEntry.cwd === sessionCwd &&
@@ -1840,6 +2097,11 @@ function registerHandlers(ipcMain) {
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
@@ -1850,6 +2112,7 @@ function registerHandlers(ipcMain) {
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
const provider = createACPProvider({
command: resolvedCommand,
@@ -1857,15 +2120,31 @@ function registerHandlers(ipcMain) {
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: sessionMcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Creating ACP provider", {
requestId,
chatSessionId,
cwd: sessionCwd,
resolvedCommand,
resolvedArgs,
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
});
}
providerEntry = {
provider,
acpCommand,
@@ -1877,15 +2156,21 @@ function registerHandlers(ipcMain) {
};
acpProviders.set(chatSessionId, providerEntry);
}
acpForceProviderReset.delete(chatSessionId);
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
throw err;
}
@@ -1901,13 +2186,22 @@ function registerHandlers(ipcMain) {
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
env: (() => {
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
}
return fallbackEnv;
})(),
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
@@ -1924,6 +2218,14 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
@@ -2042,6 +2344,9 @@ function registerHandlers(ipcMain) {
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
hasContent = true;
}
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
}
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: serialized,
@@ -2057,6 +2362,13 @@ function registerHandlers(ipcMain) {
// If stream completed with zero content, likely an auth or connection issue
if (!hasContent && !abortController.signal.aborted) {
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Stream completed with no content", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
});
}
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
@@ -2095,9 +2407,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
cleanupAcpProvider(chatSessionId);
}
acpChatRuns.delete(chatSessionId);
}
}
@@ -2127,10 +2436,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
acpForceProviderReset.add(effectiveChatSessionId);
cleanupAcpProvider(effectiveChatSessionId);
}
// Preserve the ACP provider session on stop so the next user message can
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
@@ -2143,7 +2448,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
acpForceProviderReset.delete(chatSessionId);
cleanupAcpProvider(chatSessionId);
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };

View File

@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
if (!socket.destroyed) socket.write(response);
return;
}
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -629,6 +630,22 @@ function handleExec(params) {
// ── MCP Server Config Builder ──
function resolveMcpServerRuntimeCommand() {
const runtimeCommand = process.execPath;
const runtimeEnv = [];
if (runtimeCommand && existsSync(runtimeCommand)) {
const basename = path.basename(runtimeCommand).toLowerCase();
const isNodeBinary = basename === "node" || basename.startsWith("node.");
if (!isNodeBinary) {
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
}
return { command: runtimeCommand, env: runtimeEnv };
}
return { command: "node", env: runtimeEnv };
}
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const runtime = resolveMcpServerRuntimeCommand();
const env = [
...runtime.env,
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: "node",
command: runtime.command,
args: [runtimePath],
env,
};

View File

@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
.logo {
width: 36px;
height: 36px;
color: hsl(var(--accent));
}
.brand {
font-size: 16px;
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
<body>
<div class="shell">
<div class="header">
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
stroke="currentColor" stroke-width="2" />
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
</svg>
<div>
<div class="brand">Netcatty</div>
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync.",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
autoClose: true,
})
);

View File

@@ -1858,4 +1858,5 @@ module.exports = {
renameSftp,
statSftp,
chmodSftp,
resolveEncodingForRequest,
};

View File

@@ -6,7 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
@@ -50,6 +50,9 @@ let sftpClients = null;
// Active transfers storage
const activeTransfers = new Map();
const isolatedDownloadChannelPools = new WeakMap();
// Cache sftpIds where remote cp is known to be unavailable, so we skip
// repeated failed exec attempts for each file in a multi-file transfer.
const cpUnavailableSet = new Set();
/**
* Initialize the transfer bridge with dependencies
@@ -58,6 +61,46 @@ function init(deps) {
sftpClients = deps.sftpClients;
}
/**
* Execute an SSH command with cancellation support.
* Registers an abort hook on the transfer object that closes the exec stream,
* which sends SIGHUP to the remote process.
*/
function execSshCommandCancellable(sshClient, command, transfer) {
return new Promise((resolve, reject) => {
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
sshClient.exec(command, (err, stream) => {
if (err) return reject(err);
// If cancelled between exec() call and callback, kill immediately
if (transfer.cancelled) {
try { stream.close(); } catch { }
return reject(new Error('Transfer cancelled'));
}
let stdout = '';
let stderr = '';
// Wire abort: closing the stream kills the remote process
const prevAbort = transfer.abort;
transfer.abort = () => {
try { stream.close(); } catch { }
if (typeof prevAbort === 'function') prevAbort();
};
stream.on('close', (code) => {
transfer.abort = prevAbort; // restore
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
resolve({ stdout, stderr, code });
});
stream.on('data', (data) => { stdout += data.toString(); });
stream.stderr.on('data', (data) => { stderr += data.toString(); });
});
});
}
async function openIsolatedSftpChannel(client) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") return null;
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
totalBytes,
sourceEncoding,
targetEncoding,
sameHost,
} = payload;
const sender = event.sender;
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
// Try same-host optimization first: remote cp via SSH exec.
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
let sameHostDone = false;
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
if (sameHost
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
&& !cpUnavailableSet.has(sourceSftpId)) {
const srcClient = sftpClients.get(sourceSftpId);
const sshClient = srcClient?.client;
if (sshClient && typeof sshClient.exec === 'function') {
try {
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(sourceSftpId);
}
// Other non-zero exits (permission denied, disk full, etc.)
// fall through to download+upload without caching
} catch (cpErr) {
// If cancelled, re-throw; otherwise fall back to download+upload
if (transfer.cancelled) throw cpErr;
}
}
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
if (!sameHostDone) {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
try { await fs.promises.unlink(tempPath); } catch { }
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
try { await fs.promises.unlink(tempPath); } catch { }
}
} else {
throw new Error("Invalid transfer configuration");
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
return { success: true };
}
/**
* Same-host directory copy: uses a single `cp -ra` command on the remote server
* instead of recursively transferring files one by one.
*/
async function sameHostCopyDirectory(event, payload) {
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
// Register in activeTransfers so cancelTransfer can flag it
const transfer = { cancelled: false };
if (transferId) {
activeTransfers.set(transferId, transfer);
}
try {
if (cpUnavailableSet.has(sftpId)) return { success: false };
const client = sftpClients.get(sftpId);
if (!client) return { success: false };
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
// Ensure target directory itself exists (not just its parent),
// so cp copies contents into it rather than creating a nested subdirectory.
const targetDir = targetPath.replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
try {
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 127) {
cpUnavailableSet.add(sftpId);
return { success: false };
}
if (result.code !== 0) {
return { success: false };
}
} catch (cpErr) {
if (transfer.cancelled) throw cpErr;
return { success: false };
}
return { success: true };
} finally {
if (transferId) {
activeTransfers.delete(transferId);
}
}
}
/**
* Register IPC handlers for transfer operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:transfer:start", startTransfer);
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
}
module.exports = {
@@ -762,4 +906,5 @@ module.exports = {
registerHandlers,
startTransfer,
cancelTransfer,
sameHostCopyDirectory,
};

View File

@@ -741,6 +741,9 @@ const api = {
cleanupTransferListeners(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
},
// Compressed folder upload
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
const { compressionId } = options;
@@ -1204,6 +1207,9 @@ const api = {
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
},
aiAcpCancel: async (requestId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
},

2
global.d.ts vendored
View File

@@ -346,6 +346,7 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
// Compressed folder upload
startCompressedUpload?(
@@ -383,6 +384,7 @@ declare global {
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
sameHost?: boolean;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,

View File

@@ -0,0 +1,69 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
};
function getCommandBasename(command: string | undefined): string {
const normalized = String(command || '').trim();
if (!normalized) return '';
const parts = normalized.split(/[\\/]/);
return (parts.pop() || '').toLowerCase();
}
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || '').trim();
return normalized.includes('/') || normalized.includes('\\');
}
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
const basename = getCommandBasename(command);
return basename === agentKey || basename.startsWith(`${agentKey}.`);
}
export function isSettingsManagedDiscoveredAgent(
agent: Pick<DiscoveredAgent, 'command'>,
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
const basename = getCommandBasename(agent.command);
return (
agent.id === `discovered_${agentKey}` ||
agent.acpCommand === meta.acpCommand ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managedId = `discovered_${agentKey}`;
const preferredAgent = agents.find(
(agent) =>
agent.id === managedId &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
if (preferredAgent) {
return preferredAgent.command;
}
const fallbackAgent = agents.find(
(agent) =>
matchesManagedAgentConfig(agent, agentKey) &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
return fallbackAgent?.command ?? null;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB