Compare commits

...

9 Commits

Author SHA1 Message Date
bincxz
2aad02a914 fix: replace nested button with div in session history list
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
HTML spec forbids <button> inside <button>. Change the outer session
list item from <button> to <div role="button"> to fix the hydration
warning while preserving click and keyboard accessibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:34:22 +08:00
bincxz
76baf87c29 fix: add missing abortControllersRef to useEffect dependency array
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:26:20 +08:00
陈大猫
2a75f863f8 fix: reset cloud sync connect button when OAuth popup is closed (#544)
* fix: reset cloud sync connect button when OAuth popup is closed

When users close the OAuth popup without completing authorization,
the connect button was stuck in "Connecting" state indefinitely
(up to 5-minute timeout).

Changes:
- Track OAuth popup window and poll for closure (Google, OneDrive)
- Cancel OAuth callback server when popup is closed, immediately
  rejecting the pending promise instead of waiting for timeout
- Reset provider status via disconnectProvider on auth failure so
  the connect button returns to clickable state
- Suppress toast for user-initiated cancellation (popup closed)
- Also reset GitHub provider status on device flow failure

Closes #542

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

* fix: use resetProviderStatus instead of disconnectProvider on auth failure

disconnectProvider tears down existing connections (signOut, delete
adapter, clear merge base). If a user was re-authenticating and
cancelled, this would destroy their working connection.

Add resetProviderStatus() that only resets the UI status to
'disconnected' without any teardown side effects.

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

* fix: add resetProviderStatus to CloudSyncHook interface

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

* fix: remove noreferrer from OAuth popup to enable window tracking

noreferrer implies noopener in browser spec, causing window.open()
to return null and defeating the popup closure detection entirely.
Safe to remove since OAuth targets are trusted providers (Google,
Microsoft) and the Referer is just a localhost URL.

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

* fix: guard resetProviderStatus and cancel delayed popup on early failure

- resetProviderStatus only resets if status is 'connecting', preserving
  already-authenticated providers when sync initialization fails
- Cancel the delayed setTimeout for window.open if callbackPromise
  rejects before 100ms, preventing a stray popup and leaking interval

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

* fix: reset GitHub provider status when device flow modal is closed

The modal onClose only hid the modal and stopped the polling flag,
but the provider status stayed at 'connecting'. Now calls
resetProviderStatus('github') so the button returns to clickable.

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-27 19:24:06 +08:00
陈大猫
262bc57a21 feat: enable Unicode 11 for improved Nerd Fonts rendering (#545)
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.

Closes #543 (Nerd Fonts portion)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:07:44 +08:00
bincxz
9563ae9dcc Revert "feat: enable Unicode 11 for improved Nerd Fonts rendering"
This reverts commit 349b215d3d.
2026-03-27 18:56:03 +08:00
bincxz
349b215d3d feat: enable Unicode 11 for improved Nerd Fonts rendering
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.

Closes #543 (Nerd Fonts portion)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:55:30 +08:00
Rory Chou
7639191c50 fix: preserve AI chat history across reconnects (#541)
* fix: preserve AI chat history across reconnects

* fix: retarget restored AI sessions on reconnect

* feat: format tool call results with proper line breaks

Extract stdout/stderr from structured results and unescape \n/\t
so command output displays with real line breaks like terminal output.
Supports both JSON object {stdout,stderr} and executor text formats.

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

* fix: restrict unescape to stdout/stderr fields only

Plain strings may contain legitimate backslash sequences (file paths,
regex patterns) that should not be converted. Only apply unescape to
stdout/stderr fields extracted from command execution results.

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

* fix: address review findings for AI chat reconnect

1. Add explicit activeTerminalTargetIds guard in shouldRetargetActiveSession
   to prevent retargeting sessions owned by other terminals, making the
   invariant locally verifiable.

2. Only preserve orphaned terminal sessions with hostIds — workspace,
   local, and serial sessions generate fresh IDs and would be permanently
   unreachable, wasting MAX_STORED_SESSIONS quota.

3. Clear stale streaming state when restoring a session whose ACP handle
   was already cleaned up (e.g., reconnect during mid-response), so the
   user can send new messages.

4. Restore overflow-hidden on user message bubbles to prevent content
   bleeding past rounded border corners.

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

* fix: address round 2 review findings

1. Fix streaming state clear: only clear for sessions whose targetId
   doesn't match current scope (restored from different terminal),
   not for built-in Catty chats that never set externalSessionId.

2. Exclude local/serial sessions from preservation: their synthetic
   hostIds (local-*/serial-*) change on every open and can never be
   matched back.

3. Preserve non-zero exitCode in formatted tool results so failed
   commands show a visible failure signal.

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

* fix: only clear streaming state during retarget, not for all restored sessions

The previous condition (targetId !== scopeTargetId) also fired for
built-in Catty sessions during normal operation, killing active streams.
Now streaming is only cleared when shouldRetargetActiveSession is true,
meaning the session came from a disconnected terminal where any
in-flight response is guaranteed to be dead.

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

* fix: address round 3 review findings

1. Clear externalSessionId during retarget to prevent stale ACP handle
   from surviving if retarget runs before orphan cleanup.

2. Only retarget in visible AI panels — hidden/background panels should
   not race to claim orphaned sessions.

3. Remove unescapeTerminalOutput — data flow trace confirms real newline
   characters arrive at the component. The unescape was corrupting
   legitimate backslash sequences in paths and patterns.

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

* fix: only ACP-cleanup deleted sessions, not preserved ones

Preserved sessions may be reused on reconnect. Running aiAcpCleanup
on them asynchronously could race with a newly started ACP conversation
on the same session ID, tearing down the fresh provider.

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

* fix: abort in-flight streams during retarget and restore ACP cleanup

1. Abort the active request's AbortController when retargeting a session
   with stale streaming state. Prevents late chunks from the old run
   appending into the restored chat.

2. Restore ACP cleanup for all orphaned sessions (not just deleted ones).
   Preserved sessions get a new externalSessionId on next use, so
   cleaning the old one prevents subprocess leaks without affecting
   future conversations.

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

* fix: guard hidden panels from session ownership and skip null map entries

1. Only assign restored sessions in visible panels — hidden panels
   should not race to own sessions via setActiveSessionId, preventing
   MCP/tool calls from being bound to the wrong terminal.

2. Skip null entries in activeSessionIdMap when building
   activeTerminalTargetIds — deleted chats should not block same-host
   history matching on other terminals.

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

* fix: guard MCP sync behind visibility and cancel exec/approvals on retarget

1. Only sync MCP session metadata from visible panels to prevent
   hidden panels from overwriting the scope mapping.

2. Cancel pending approvals and in-flight exec (Catty + ACP) during
   retarget, matching handleStop behavior. Prevents stale tool results
   and approval prompts from reappearing after session retarget.

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

* fix: restore MCP sync for hidden panels

MCP scope is keyed by chatSessionId so hidden panels don't overwrite
visible panels' mappings. The isVisible guard was breaking background
chats that need updated terminal session metadata after reconnects
or workspace changes.

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

* chore: remove unused deletedIds variable

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:47:32 +08:00
陈大猫
c3224d30c6 feat: network device mode for SSH + serial charset encoding support (#540)
* feat: add deviceType field to Host model for network device support

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

* feat: pass deviceType through session metadata pipeline

Thread deviceType from Host model through AITerminalSessionInfo, IPC
types, and mcpServerBridge so AI agents can inspect device type per session.

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

* feat: route network device SSH sessions to raw PTY execution

When deviceType === 'network', handleExec now uses execViaRawPty
instead of execViaPty so vendor CLIs (Huawei VRP, Cisco IOS, etc.)
receive commands as-is without POSIX shell wrapping or markers.
The command blocklist is also skipped for network devices, consistent
with the existing serial session bypass. AI context description updated
to document the raw-execution behaviour for network device sessions.

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

* feat: add network device mode toggle to host settings UI

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

* feat: add network device awareness to Catty Agent system prompt

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

* fix: extend network device mode to Catty Agent exec path and host context

- Add network device detection and raw execution routing to aiBridge.cjs
  (the primary Catty Agent command path), not just the MCP bridge
- Export getSessionMeta from mcpServerBridge for reuse in aiBridge
- Surface deviceType in Catty Agent system prompt host list so the AI
  can identify which sessions are network devices
- Pass deviceType through buildSystemPrompt context

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

* fix: exempt network device sessions from client-side blocklist and update ACP context

- Add deviceType to ExecutorContext sessions type
- Skip renderer-side command blocklist for deviceType=network sessions
  in shared toolExecutors.ts (not just main-process side)
- Update ACP agent context hint to mention network device sessions

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

* fix: only show network device mode toggle for SSH hosts

Telnet and local hosts don't support the network device execution path,
so hiding the toggle prevents users from enabling a broken configuration.
Serial hosts already use raw mode by default.

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

* fix: exclude Mosh sessions from network device raw execution path

Mosh uses a shell-backed PTY and cannot connect to vendor CLIs, so
network device mode should only apply to SSH and serial sessions.

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

* fix: prefer session.protocol over metadata for Mosh detection

Mosh tabs report protocol:"ssh" in renderer metadata but "mosh" in
the main-process session object. Prioritize session.protocol (runtime
truth) to correctly exclude Mosh from network device raw execution.

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

* fix: suppress deviceType metadata for Mosh sessions

Mosh requires a shell-backed PTY and cannot connect to vendor CLIs,
so omit deviceType from AI-facing metadata when session is Mosh-backed.
This prevents the AI from being told to use vendor CLI syntax when the
actual execution path uses normal shell wrapping.

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

* fix: use exit code 0 for network device sessions and hide toggle for Mosh

- Network device / serial sessions return exitCode: null from vendor
  CLIs. Default to 0 instead of -1 so the AI doesn't misinterpret
  successful commands as failures.
- Hide the network device mode toggle when Mosh is enabled, since
  the setting is suppressed at runtime for Mosh sessions anyway.

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

* fix: preserve null exit codes and restrict raw mode to SSH/serial

- Preserve exitCode: null for network device sessions instead of
  coercing to 0, so the AI knows exit status is unavailable rather
  than seeing a misleading success code.
- Explicitly whitelist SSH/serial protocols for network device mode
  instead of just excluding mosh, preventing local/telnet sessions
  from accidentally entering raw execution.

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

* fix: use UTF-8 encoding for SSH network device raw execution

execViaRawPty hardcodes latin1 for serial port data decoding. Add an
encoding option (default: latin1) and pass utf8 from SSH network
device call sites so multi-byte characters aren't corrupted.

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

* fix: use host charset for serial port decoding instead of hardcoded latin1

- Extract charsetToNodeEncoding() to module scope in terminalBridge
- Serial sessions now read options.charset (from Host.charset) for
  both terminal display decoding and AI command output
- Store serialEncoding on session object so exec paths can use it
- Pass encoding through all execViaRawPty call sites
- Default encoding changed from latin1 to utf8 (matches most modern
  network equipment and is the safer default for CJK environments)

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

* fix: move serialEncoding declaration before session object creation

serialEncoding was referenced in the session object literal before its
const declaration, causing a TDZ ReferenceError that would crash every
serial connection.

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

* fix: tighten isNetworkDevice logic and clean up edge cases

- Align toolExecutors isNetworkDevice check with bridge logic: require
  explicit SSH/serial protocol match instead of trusting deviceType alone
- Remove empty-string protocol match from isSshOrSerial in both bridges
  to prevent local/unknown sessions from being treated as network devices
- Widen exitCode return type to `number | null` to match actual behavior
- Clear deviceType when enabling Mosh (incompatible combination)

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

* fix: update MCP server tool descriptions for network device sessions

The get_environment and terminal_execute tool descriptions only
mentioned serial/raw sessions for network devices. Updated to also
reference deviceType: network SSH sessions so external AI agents
(Claude, Codex) know about the new execution mode.

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

* fix: include deviceType in get_environment and guard execViaChannel fallback

- Add deviceType to executeWorkspaceGetInfo session mapping and return
  type so Catty Agent's get_environment tool matches MCP bridge output
- Guard both aiBridge and mcpServerBridge against falling through to
  execViaChannel for network device sessions — network devices require
  an interactive PTY and exec channels would produce broken behavior

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

* feat: add charset setting to serial host configuration UI

Serial hosts now have a charset input in the Advanced section,
defaulting to UTF-8. The value is saved to Host.charset and used
by the serial decoder in terminalBridge.

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

* feat: add charset to serial quick-connect modal with full pipeline

- Add charset input to SerialConnectModal (Advanced section)
- Thread charset through onConnect callback → handleConnectSerial →
  createSerialSession → TerminalSession.charset
- Add charset field to TerminalSession interface
- Include charset in fallback host builder for quick-connect sessions
  so createTerminalSessionStarters can pass it to startSerialSession
- Saved hosts also store charset via onSaveHost

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

* fix: constrain serial connect modal height with scrollable content

Modal content could overflow the viewport when Advanced section was
expanded. Add max-h-[85vh] to DialogContent with flex layout so the
content area scrolls while header and footer buttons stay visible.

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

* fix: propagate charset through all serial session creation paths

- Add charset to startSerialSession type in global.d.ts
- Copy host.charset to TerminalSession in connectToHost serial path
- Copy host.charset in createWorkspaceWithHosts serial path
- Propagate session.charset in splitSession (both workspace and standalone)
- Propagate session.charset in copySession

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

* fix: propagate charset in remaining session creation paths

Add host.charset to connectToHost (non-serial), createWorkspaceWithHosts
(non-serial), and runSnippet session creation for consistency.

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-27 18:33:16 +08:00
陈大猫
40d80fe535 perf: comprehensive UI and state management optimization (#539)
* perf: comprehensive performance optimization across UI and state management

- Replace Array.find/includes with Map/Set lookups for O(1) access in hot paths
- Add requestAnimationFrame throttling to all mousemove resize handlers
- Remove redundant forceUpdate + useSyncExternalStore double subscription
- Extract terminal search decoration config to module-level constant
- Pause server stats polling and resize handlers for hidden terminals
- Add timer cleanup for useEffect/useLayoutEffect with setTimeout
- Use useEffectEvent to stabilize effect callbacks and reduce effect re-runs
- Use useDeferredValue for QuickSwitcher search input
- Batch activeTabStore notifications with microtask coalescing
- Memoize sessionLogConfig and activityTrackedSessions to prevent child re-renders
- Use ref pattern for stable onTerminalDataCapture callback
- Skip TerminalLayer pre-warming when no sessions or workspaces exist

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

* fix: flush final resize value before canceling RAF

Apply the last computed size synchronously on mouseup/cleanup before
canceling the pending requestAnimationFrame. This prevents the final
drag delta from being dropped when mouseup fires before the queued
frame executes.

Addresses review feedback from codex on all 3 RAF-throttled resize
handlers: split resize, side panel resize, and SFTP column resize.

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

* fix: initialize lastClientXRef on resize start to prevent click-collapse

Without initialization, a click on the resize handle without dragging
would use lastClientXRef=0, computing a large negative diff and
collapsing the column to minimum width.

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

* fix: revert useDeferredValue for QuickSwitcher search

useDeferredValue can lag behind the actual input, causing quickResults
to reflect a stale query when the user types fast and presses Enter.
This is a correctness regression - the selected item may not match the
user's intent. The host list is typically small (<200), so synchronous
filtering is fast enough without deferral.

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

* fix: restore runtime activity guard to prevent stale badge on tab switch

The pre-filtered activityTrackedSessions reduces subscriptions for
disconnected sessions, but removing the runtime shouldMarkSessionActivity
check introduced a race: between tab switch and effect re-subscription,
old listeners could mark the newly-focused session as unread. Restore
the activeTabIdRef.current guard inside the callback as a safety net.

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

* fix: defer initialConnectDoneRef flag until auto-connect executes

Moving the flag inside the setTimeout callback prevents it from being
set when the timer is canceled by cleanup. Previously, if the effect
re-ran before the setTimeout(0) fired, the timer was cleared but the
ref was already true, permanently skipping the initial local connect.

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

* fix: capture resizingRef fields before setState updater

Destructure field/startX/startWidth from the ref upfront so the
functional updater closure never reads resizingRef.current after
it may have been cleared by handleResizeEnd.

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

* fix: remove activeTabId from activityTrackedSessions to stabilize subscriptions

Depending on activeTabId caused subscriptions to tear down and recreate
on every tab switch, resetting the ChunkedEscapeFilter mid-sequence and
producing false unread badges. The runtime guard via activeTabIdRef
already handles the active-tab check, so pre-filtering only needs to
exclude disconnected sessions.

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

* fix: fetch server stats immediately when tab becomes visible again

Use hasFetchedRef to distinguish first connect (2s delay for connection
stabilization) from tab resume (immediate fetch). Prevents showing
stale CPU/memory data for 2 seconds after switching back to a terminal.

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

* fix: restore cold-start prewarm and reset network stats on tab resume

1. Revert shouldPrewarm guard - TerminalLayer should always prewarm
   after 1.2s regardless of session/workspace count, as the purpose is
   to hide lazy-load latency before the user opens their first terminal.

2. Reset netRxSpeed/netTxSpeed to 0 when resuming a hidden terminal
   tab. The backend computes network throughput as a delta from the
   previous sample, so the first fetch after a long hidden interval
   would show artificially low throughput averaged over the gap.

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

* fix: reset hasFetchedRef on disconnect and preserve built-in theme precedence

1. Clear hasFetchedRef when connection drops so reconnects get the 2s
   stabilization delay before first stats fetch.

2. Reverse theme merge order in themeById Map so built-in themes are
   written last and take precedence over custom themes with duplicate
   IDs, matching the original find() semantics and other resolution
   sites (customThemeStore.getThemeById, Terminal.tsx).

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

* fix: also clear per-interface network speeds on tab resume

Reset rxSpeed/txSpeed on each netInterfaces entry in addition to the
aggregate values, so the network hovercard doesn't show stale
throughput while waiting for the first fresh poll after resume.

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

* fix: reset capture ref on retry and skip warmup for established connections

1. Reset terminalDataCapturedRef in handleRetry() so log capture works
   for retried sessions (retry doesn't change sessionId, so the effect
   that resets the ref never re-runs).

2. Track connection start time to skip the 2s warmup delay when a tab
   becomes visible for a connection that was already established while
   hidden. Only apply the warmup for truly fresh connections (<2s old).

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

* fix: prevent overlapping stats requests and track connection time while hidden

1. Add fetchInFlightRef guard to prevent concurrent getServerStats
   requests that could race and corrupt baseline CPU/network data.

2. Move connectedAtRef initialization before the isVisible check so
   connections that complete while the tab is hidden record their
   start time. This ensures the warmup delay is correctly skipped
   when the tab becomes visible for an already-stable connection.

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

* fix: reset fetchInFlightRef on disconnect to unblock reconnect stats

A pending getServerStats request from a previous connection could keep
fetchInFlightRef set, causing the reconnected session's initial fetch
to be skipped until the old request timed out.

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

* fix: clear fetchInFlightRef when tab becomes hidden

Ensures the resume fetch isn't blocked by an in-flight request from
the previous visible cycle. Any stale response from the old request
will be quickly overwritten by the fresh immediate fetch on resume.

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

* fix: use generation counter to invalidate stale stats responses

Replace fetchInFlightRef with a generation counter that increments on
each fetch. Stale responses from before a hide/show cycle are discarded
by comparing the captured generation against the current value, fully
preventing pre-hide requests from overwriting zeroed network stats.

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

* fix: increment fetch generation on effect setup to invalidate in-flight requests

The generation was only incremented inside fetchStats, but the resume
setTimeout hadn't fired yet when old responses arrived. Incrementing
at effect setup time ensures any pre-hide in-flight request is
immediately stale, preventing it from overwriting zeroed network stats.

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-27 14:45:47 +08:00
42 changed files with 1054 additions and 419 deletions

363
App.tsx
View File

@@ -1,4 +1,4 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
@@ -286,30 +286,48 @@ function App({ settings }: { settings: SettingsState }) {
const customThemes = useCustomThemes();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
const host = hosts.find(h => h.id === s.hostId) ?? null;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return TERMINAL_THEMES.find(t => t.id === themeId)
|| customThemes.find(t => t.id === themeId)
|| currentTerminalTheme;
return themeById.get(themeId) || currentTerminalTheme;
};
// Workspace
const workspace = workspaces.find(w => w.id === activeTabId);
const workspace = workspaceById.get(activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = sessions.find(s => s.id === workspace.focusedSessionId)
?? sessions.find(s => wsSessionIds.includes(s.id));
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
// Split mode: require all sessions to share the same theme
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as TerminalSession[];
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
@@ -317,10 +335,10 @@ function App({ settings }: { settings: SettingsState }) {
}
// Single session tab
const session = sessions.find(s => s.id === activeTabId);
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
isImmersive: immersiveMode,
@@ -378,6 +396,144 @@ function App({ settings }: { settings: SettingsState }) {
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => {
const session = sessions.find((item) => item.id === sessionId);
if (session?.workspaceId) {
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
return;
}
setActiveTabId(sessionId);
});
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => {
const rule = portForwardingRules.find((item) => item.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
}
void stopTunnel(ruleId);
});
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => {
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
});
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
});
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
});
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
@@ -484,110 +640,34 @@ function App({ settings }: { settings: SettingsState }) {
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
const rule = portForwardingRules.find((r) => r.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
void stopTunnel(ruleId);
}
_handleTrayTogglePortForward(ruleId, start);
});
return () => {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, []);
// Tray panel actions (from main process)
useEffect(() => {
const handlerJump = (sessionId: string) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
};
const handlerConnect = (hostId: string) => {
const host = hosts.find((h) => h.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
};
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
const unsubscribeJump = bridge.onTrayPanelJumpToSession((sessionId) => {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
});
return () => {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, []);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -904,96 +984,21 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
const isMac = hotkeyScheme === 'mac';
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Registering global hotkey handler, scheme:', hotkeyScheme, 'bindings count:', keyBindings.length);
}
const handleGlobalKeyDown = (e: KeyboardEvent) => {
// Don't handle if we're in an input or textarea (except for Escape)
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
// Check each key binding
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
// SFTP shortcuts are handled by SFTP-specific hooks.
if (binding.category === 'sftp') {
continue;
}
// Terminal-specific actions should be handled by the terminal
// Don't handle them at app level
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return; // Let terminal handle it
}
continue; // Ignore terminal actions outside terminal
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
_handleGlobalHotkeyKeyDown(e);
};
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, keyBindings, isHotkeyRecording, executeHotkeyAction]);
}, [hotkeyScheme, isHotkeyRecording]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isQuickSwitcherOpen]);
}, []);
const quickResults = useMemo(() => {
if (!isQuickSwitcherOpen) return [];
@@ -1006,7 +1011,7 @@ function App({ settings }: { settings: SettingsState }) {
)
: hosts;
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
}, [quickSearch, hosts, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -1095,10 +1100,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
const sessionId = createSerialSession(config, options);
addConnectionLog({
sessionId,
hostId: '',

View File

@@ -925,6 +925,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
@@ -1533,6 +1537,7 @@ const en: Messages = {
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.charset': 'Charset',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',

View File

@@ -604,6 +604,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.deviceType': '设备类型',
'hostDetails.deviceType': '网络设备模式',
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
@@ -1547,6 +1551,7 @@ const zhCN: Messages = {
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.field.charset': '字符编码',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',

View File

@@ -6,6 +6,7 @@ type Listener = () => void;
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
getActiveTabId = () => this.activeTabId;
@@ -13,7 +14,10 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
});
}

View File

@@ -496,32 +496,39 @@ export const useSftpConnections = ({
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
const timer = window.setTimeout(() => {
initialConnectDoneRef.current = true;
connect("left", "local");
}, 0);
return () => window.clearTimeout(timer);
}
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
const reconnectTimers: number[] = [];
const scheduleReconnect = (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && reconnectingRef.current[side]) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (reconnectingRef.current[side]) {
connect(side, lastHost);
}
}
if (!lastHost || !reconnectingRef.current[side]) return;
const timer = window.setTimeout(() => {
if (!reconnectingRef.current[side]) return;
void connect(side, lastHost);
}, 1000);
reconnectTimers.push(timer);
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
scheduleReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
scheduleReconnect("right");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
return () => {
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
};
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
const disconnect = useCallback(
async (side: "left" | "right") => {

View File

@@ -47,27 +47,63 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const removedSessionIds = currentSessions
const orphanedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (removedSessionIds.length === 0) return;
if (orphanedSessionIds.length > 0) {
const orphanedSessionIdSet = new Set(orphanedSessionIds);
cleanupAcpSessions(removedSessionIds);
// Determine which sessions can be restored via host-based matching
const preservedIds = new Set<string>();
for (const session of currentSessions) {
if (!orphanedSessionIdSet.has(session.id)) continue;
// Only preserve remote terminal sessions with real hostIds
const isRestorable = session.scope.type === 'terminal'
&& session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
if (isRestorable) {
preservedIds.add(session.id);
}
}
const removedSessionIdSet = new Set(removedSessionIds);
// Cleanup ACP sessions for all orphans (both deleted and preserved).
// Preserved sessions will get a new externalSessionId on next use,
// so cleaning the old one is safe and prevents subprocess leaks.
cleanupAcpSessions(orphanedSessionIds);
const nextSessions = currentSessions.filter((session) => {
if (!session.scope.targetId) return true;
return activeTargetIds.has(session.scope.targetId);
});
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
const nextSessions = currentSessions
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
.map((session) => {
if (!preservedIds.has(session.id) || !session.externalSessionId) {
return session;
}
// Drop transient ACP session handles so the next turn starts cleanly.
return { ...session, externalSessionId: undefined };
});
const sessionsChanged = nextSessions.length !== currentSessions.length
|| nextSessions.some((session, index) => session !== currentSessions[index]);
if (sessionsChanged) {
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
}
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
@@ -75,11 +111,10 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (sessionId && removedSessionIdSet.has(sessionId)) {
nextActiveSessionIdMap[scopeKey] = null;
activeSessionMapChanged = true;
}
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
@@ -126,6 +161,19 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
function buildScopeKey(scope: AISessionScope) {
return `${scope.type}:${scope.targetId ?? ''}`;
}
function areHostIdsEqual(left?: string[], right?: string[]) {
const leftIds = left ?? [];
const rightIds = right ?? [];
if (leftIds.length !== rightIds.length) return false;
const rightSet = new Set(rightIds);
return leftIds.every((hostId) => rightSet.has(hostId));
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -598,6 +646,61 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
if (!currentSession) return;
const currentScope = currentSession.scope;
const scopeChanged =
currentScope.type !== scope.type
|| currentScope.targetId !== scope.targetId
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
const nextScopeKey = buildScopeKey(scope);
const currentScopeKey = buildScopeKey(currentScope);
if (scopeChanged) {
setSessionsRaw((prev) => {
let changed = false;
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
// Clear stale ACP handle — retarget may run before orphan cleanup
return { ...session, scope, externalSessionId: undefined };
});
if (!changed) return prev;
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}
setActiveSessionIdMapRaw((prev) => {
let changed = false;
const next = { ...prev };
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
delete next[currentScopeKey];
changed = true;
}
if (next[nextScopeKey] !== sessionId) {
next[nextScopeKey] = sessionId;
changed = true;
}
if (!changed) return prev;
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -750,6 +853,7 @@ export function useAIState() {
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -6,7 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
type SecurityState,
@@ -82,7 +82,8 @@ export interface CloudSyncHook {
redirectUri: string
) => Promise<void>;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
@@ -120,17 +121,6 @@ const getSnapshot = (): SyncManagerState => {
};
export const useCloudSync = (): CloudSyncHook => {
// Force update mechanism to ensure React re-renders
const [, forceUpdate] = useState(0);
// Subscribe to state changes and force update
useEffect(() => {
const unsubscribe = manager.subscribeToStateChanges(() => {
forceUpdate(n => n + 1);
});
return unsubscribe;
}, []);
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -266,7 +256,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -274,32 +264,48 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
// 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);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
try {
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
}
}
return data.url;
}, []);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -307,22 +313,38 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
// 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);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
try {
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
}
}
return data.url;
}, []);
@@ -338,6 +360,10 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.disconnectProvider(provider);
}, []);
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
manager.resetProviderStatus(provider);
}, []);
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
await manager.connectConfigProvider('webdav', config);
}, []);
@@ -444,7 +470,8 @@ export const useCloudSync = (): CloudSyncHook => {
connectS3,
completePKCEAuth,
disconnectProvider,
resetProviderStatus,
// Sync Actions
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,

View File

@@ -58,7 +58,7 @@ export const useSessionState = () => {
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
@@ -71,6 +71,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: config,
charset: options?.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -103,6 +104,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -120,6 +122,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
@@ -321,6 +324,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
@@ -334,6 +338,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
});
@@ -445,8 +450,9 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
// Add pane to existing workspace
const hint: SplitHint = {
direction,
@@ -476,13 +482,14 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
@@ -563,6 +570,7 @@ export const useSessionState = () => {
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
@@ -649,6 +657,7 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
};
@@ -682,9 +691,11 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
@@ -698,10 +709,12 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);

View File

@@ -56,6 +56,7 @@ interface AIChatSidePanelProps {
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -103,6 +104,7 @@ interface AIChatSidePanelProps {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
@@ -152,6 +154,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
});
}
function getSessionScopeMatchRank(
session: AISession,
scopeType: 'terminal' | 'workspace',
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -164,6 +187,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -227,21 +251,115 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session) {
setCurrentAgentId(session.agentId);
const activeTerminalTargetIds = useMemo(() => {
const targetIds = new Set<string>();
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
const targetId = sessionScopeKey.slice('terminal:'.length);
if (!targetId || targetId === scopeTargetId) continue;
targetIds.add(targetId);
}
return targetIds;
}, [activeSessionIdMap, scopeTargetId]);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
);
const activeSession = useMemo(() => {
if (activeSessionIdForScope) {
const session = sessions.find((s) => s.id === activeSessionIdForScope);
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
return session;
}
}
}, [scopeKey, activeSessionId, sessions]);
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const shouldRetargetActiveSession = useMemo(() => {
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
return false;
}
// Don't retarget sessions that are actively owned by another terminal
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
return false;
}
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
useEffect(() => {
if (!activeSession) return;
if (shouldRetargetActiveSession && isVisible) {
// Full cleanup of any in-flight work — the session came from a disconnected
// terminal, so any active response, pending approvals, or exec is dead.
if (streamingSessionIds.has(activeSession.id)) {
const controller = abortControllersRef.current.get(activeSession.id);
if (controller) {
controller.abort();
abortControllersRef.current.delete(activeSession.id);
}
setStreamingForScope(activeSession.id, false);
clearAllPendingApprovals(activeSession.id);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSession.id);
bridge?.aiAcpCancel?.('', activeSession.id);
}
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
return;
}
if (isVisible && activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
}, [
activeSession,
activeSessionIdForScope,
retargetSessionScope,
isVisible,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
setStreamingForScope,
shouldRetargetActiveSession,
streamingSessionIds,
abortControllersRef,
]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSession) {
setCurrentAgentId(activeSession.agentId);
}
}, [scopeKey, activeSession]);
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
@@ -294,12 +412,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[enableAgent, setExternalAgents],
);
// Active session (scoped)
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const messages = activeSession?.messages ?? [];
// ── Export hook ──
@@ -345,15 +457,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Filtered sessions for history (matching current scope type)
const historySessions = useMemo(
() =>
sessions
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
.sort((a, b) => b.updatedAt - a.updatedAt),
[sessions, scopeType, scopeTargetId],
);
// -------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------
@@ -420,14 +523,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
return activeSessionId;
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
if (shouldRetargetActiveSession) {
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
} else if (activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
return activeSession.id;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
}, [
activeSession,
activeSessionIdForScope,
createSession,
currentAgentId,
retargetSessionScope,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
shouldRetargetActiveSession,
]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
@@ -747,9 +870,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
const timeStr = formatRelativeTime(time, t);
return (
<button
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
@@ -770,7 +896,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<Trash2 size={12} />
</button>
</div>
</button>
</div>
);
})
)}

View File

@@ -800,6 +800,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
toast.success(t('cloudSync.connect.github.success'));
} catch (error) {
setIsPollingGitHub(false);
setShowGitHubModal(false);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('github');
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
}
@@ -813,10 +816,13 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.google.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
}
};
@@ -828,10 +834,13 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.onedrive.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
}
};
@@ -1250,6 +1259,9 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => {
setShowGitHubModal(false);
setIsPollingGitHub(false);
// Reset provider status so button is clickable again.
// The background polling will continue until expiry but is harmless.
sync.resetProviderStatus('github');
}}
/>

View File

@@ -25,6 +25,7 @@ import {
Trash2,
Variable,
Wifi,
Router,
X,
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
@@ -1515,7 +1516,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
onToggle={() => update("moshEnabled", !form.moshEnabled)}
onToggle={() => {
const enabling = !form.moshEnabled;
if (enabling && form.deviceType === 'network') {
// Network device mode is incompatible with Mosh — clear it
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
} else {
update("moshEnabled", enabling);
}
}}
/>
</Card>
@@ -1548,6 +1557,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Router size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
</div>
<ToggleRow
label={t("hostDetails.deviceType")}
enabled={form.deviceType === 'network'}
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.deviceType.desc")}
</p>
{form.deviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</Card>
)}
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">

View File

@@ -98,13 +98,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Reset state when opening
useEffect(() => {
if (isOpen) {
setSelectedIndex(0);
// Auto focus the input after a short delay
setTimeout(() => {
inputRef.current?.focus();
}, 50);
}
if (!isOpen) return;
const focusTimer = window.setTimeout(() => {
inputRef.current?.focus();
}, 50);
setSelectedIndex(0);
return () => {
window.clearTimeout(focusTimer);
};
}, [isOpen]);
// Handle clicks outside the container

View File

@@ -35,7 +35,7 @@ interface SerialPort {
interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
onSaveHost?: (host: Host) => void;
}
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const [charset, setCharset] = useState('UTF-8');
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
charset,
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
onConnect(config);
onConnect(config, { charset });
onClose();
};
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb size={18} />
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
{/* Serial Port Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -66,6 +66,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
@@ -107,6 +108,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
port: baudRate,
tags,
group,
charset,
serialConfig: config,
};
@@ -392,6 +394,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -240,6 +240,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
@@ -247,6 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
@@ -494,6 +497,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isConnected: status === 'connected',
isVisible,
});
useEffect(() => {
@@ -582,6 +586,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
const captureHandler = onTerminalDataCaptureRef.current;
if (!captureHandler || terminalDataCapturedRef.current) return;
terminalDataCapturedRef.current = true;
captureHandler(capturedSessionId, data);
}, []);
const cleanupSession = () => {
disposeDataRef.current?.();
@@ -649,7 +659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
},
onSessionExit,
onTerminalDataCapture,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onOsDetected,
onCommandExecuted,
sessionLog,
@@ -658,6 +668,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
@@ -775,11 +786,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
disposed = true;
if (onTerminalDataCapture && serializeAddonRef.current) {
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
try {
const terminalData = serializeAddonRef.current.serialize();
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
onTerminalDataCapture(sessionId, terminalData);
handleTerminalDataCaptureOnce(sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data on unmount:", err);
}
@@ -787,7 +798,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
teardown();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
}, [host.id, sessionId]);
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
// Connection timeline and timeout visuals
useEffect(() => {
@@ -1176,6 +1187,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [sessionId]);
useEffect(() => {
if (!isVisible) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
@@ -1193,7 +1206,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (resizeTimeout) clearTimeout(resizeTimeout);
window.removeEventListener("resize", handler);
};
}, []);
}, [isVisible]);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
@@ -1343,6 +1356,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setStatus("connecting");

View File

@@ -204,6 +204,7 @@ type AITerminalSessionInfo = {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
};
@@ -235,6 +236,9 @@ const buildAITerminalSessionInfo = (
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
// Suppress deviceType for Mosh sessions — Mosh requires a shell-backed PTY
// and cannot connect to vendor CLIs, so network device mode doesn't apply.
deviceType: (session?.moshEnabled || host?.moshEnabled) ? undefined : host?.deviceType,
connected: session?.status === 'connected',
};
};
@@ -297,6 +301,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
retargetSessionScope={aiState.retargetSessionScope}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
@@ -730,12 +735,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const startWidth = sidePanelWidth;
let lastWidth = startWidth;
let rafId: number | null = null;
const onMouseMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
setSidePanelWidth(lastWidth);
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
setSidePanelWidth(lastWidth);
});
};
const onMouseUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
setSidePanelWidth(lastWidth);
sftpResizingRef.current = false;
persistSidePanelWidth(lastWidth);
window.removeEventListener('mousemove', onMouseMove);
@@ -789,6 +801,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tags: [],
protocol: session.protocol ?? 'local' as const,
moshEnabled: session.moshEnabled,
charset: session.charset,
});
}
}
@@ -819,6 +832,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const validSessionActivityIds = useMemo(() => {
return getValidSessionActivityIds(sessions);
}, [sessions]);
const activityTrackedSessions = useMemo(
() =>
sessions.filter(
(session) => session.status !== 'disconnected',
),
[sessions],
);
const onSplitSessionRef = useRef(onSplitSession);
onSplitSessionRef.current = onSplitSession;
@@ -1035,15 +1055,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
let rafId: number | null = null;
let lastDelta = 0;
const applySizes = () => {
const dimension = resizing.direction === 'vertical' ? resizing.startArea.w : resizing.startArea.h;
if (dimension <= 0) return;
const total = resizing.startSizes.reduce((acc, n) => acc + n, 0) || 1;
const pxSizes = resizing.startSizes.map(s => (s / total) * dimension);
const i = resizing.index;
const delta = (resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y);
let a = pxSizes[i] + delta;
let b = pxSizes[i + 1] - delta;
let a = pxSizes[i] + lastDelta;
let b = pxSizes[i + 1] - lastDelta;
const minPx = Math.min(120, dimension / 2);
if (a < minPx) {
const diff = minPx - a;
@@ -1062,10 +1083,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const newSizes = newPxSizes.map(n => n / totalPx);
onUpdateSplitSizes(resizing.workspaceId, resizing.splitId, newSizes);
};
const onUp = () => setResizing(null);
const onMove = (e: MouseEvent) => {
lastDelta = resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
applySizes();
});
};
const onUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
applySizes();
setResizing(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
@@ -1265,7 +1299,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [activeTabId, sessions]);
useEffect(() => {
const unsubscribers = sessions.map((session) => {
const unsubscribers = activityTrackedSessions.map((session) => {
const filter = new ChunkedEscapeFilter();
return onSessionData(session.id, (chunk) => {
if (!hasNotifiableTerminalOutput(filter, chunk)) return;
@@ -1283,7 +1317,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
unsubscribe();
}
};
}, [onSessionData, sessions]);
}, [activityTrackedSessions, onSessionData]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
@@ -1613,6 +1647,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat],
);
// Resolve the effective theme for the compose bar in workspace mode
const composeBarThemeColors = useMemo(() => {
@@ -1712,7 +1753,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [activeWorkspace]);
const workspaceSessions = useMemo(() => {
return sessions.filter(s => workspaceSessionIds.includes(s.id));
const idSet = new Set(workspaceSessionIds);
return sessions.filter(s => idSet.has(s.id));
}, [sessions, workspaceSessionIds]);
// Render focus mode sidebar
@@ -2152,7 +2194,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
onSnippetExecutorChange={handleSnippetExecutorChange}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
sessionLog={sessionLogConfig}
/>
</div>
);

View File

@@ -115,7 +115,7 @@ interface VaultViewProps {
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onCreateLocalTerminal: () => void;
onConnectSerial?: (config: SerialConfig) => void;
onConnectSerial?: (config: SerialConfig, options?: { charset?: string }) => void;
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
@@ -2548,9 +2548,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config) => {
onConnect={(config, options) => {
if (onConnectSerial) {
onConnectSerial(config);
onConnectSerial(config, options);
}
}}
onSaveHost={(host) => {

View File

@@ -25,8 +25,8 @@ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}

View File

@@ -5,6 +5,39 @@ import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
* Format tool result for display. Extracts stdout/stderr from structured
* command results for terminal-like output.
*/
function formatToolResult(result: unknown): string {
let parsed = result;
if (typeof parsed === 'string') {
try {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
}
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
const parts: string[] = [];
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
parts.push(`exit code: ${obj.exitCode}`);
}
if (parts.length > 0) return parts.join('\n');
}
}
if (typeof parsed === 'string') return parsed;
return JSON.stringify(parsed, null, 2);
}
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
args?: Record<string, unknown>;
@@ -133,7 +166,7 @@ export const ToolCall = ({
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
@@ -174,10 +207,10 @@ export const ToolCall = ({
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
<pre className={cn(
'text-[11px] font-mono whitespace-pre-wrap break-all',
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
{formatToolResult(result)}
</pre>
</div>
)}

View File

@@ -229,7 +229,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled || isStreaming}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
/>
<button

View File

@@ -126,6 +126,7 @@ export interface TerminalSessionInfo {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}
@@ -688,6 +689,7 @@ export function useAIChatStreaming({
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
deviceType: s.deviceType,
connected: s.connected,
})),
permissionMode: context.globalPermissionMode,

View File

@@ -147,7 +147,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
setTimeout(updateScrollState, 100);
const timer = setTimeout(updateScrollState, 100);
return () => clearTimeout(timer);
}, [activeTabId, updateScrollState]);
// Drag handlers

View File

@@ -108,10 +108,10 @@ export const useSftpPaneDragAndSelect = ({
e.preventDefault();
return;
}
const selectedNames = Array.from(selectedFilesRef.current);
const files = selectedNames.includes(entry.name)
const selectedNames = new Set(selectedFilesRef.current);
const files = selectedNames.has(entry.name)
? sortedFilesRef.current
.filter((f) => selectedNames.includes(f.name))
.filter((f) => selectedNames.has(f.name))
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),

View File

@@ -34,24 +34,41 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
const rafIdRef = useRef<number | null>(null);
const lastClientXRef = useRef(0);
const applyColumnWidth = useCallback(() => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
Math.min(60, startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
[field]: newWidth,
}));
}, []);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
lastClientXRef.current = e.clientX;
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
applyColumnWidth();
});
}, [applyColumnWidth]);
const handleResizeEnd = useCallback(() => {
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
applyColumnWidth();
rafIdRef.current = null;
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
}, [applyColumnWidth, handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
@@ -59,6 +76,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
) => {
e.preventDefault();
e.stopPropagation();
lastClientXRef.current = e.clientX;
resizingRef.current = {
field,
startX: e.clientX,

View File

@@ -50,6 +50,7 @@ interface UseServerStatsOptions {
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
isVisible: boolean; // Pause background polling for hidden terminals
}
export function useServerStats({
@@ -58,6 +59,7 @@ export function useServerStats({
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
cpu: null,
@@ -84,9 +86,12 @@ export function useServerStats({
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const hasFetchedRef = useRef(false);
const connectedAtRef = useRef(0);
const fetchGenerationRef = useRef(0);
const fetchStats = useCallback(async () => {
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
return;
}
@@ -95,15 +100,18 @@ export function useServerStats({
return;
}
const generation = ++fetchGenerationRef.current;
setIsLoading(true);
setError(null);
try {
const result = await bridge.getServerStats(sessionId);
if (!isMountedRef.current) return;
// Discard stale responses from before a hide/show cycle or reconnect
if (!isMountedRef.current || generation !== fetchGenerationRef.current) return;
if (result.success && result.stats) {
hasFetchedRef.current = true;
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
@@ -129,15 +137,15 @@ export function useServerStats({
setError(result.error);
}
} catch (err) {
if (isMountedRef.current) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setIsLoading(false);
}
}
}, [sessionId, enabled, isSupportedOs, isConnected]);
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
// Initial fetch and periodic refresh
useEffect(() => {
@@ -150,7 +158,10 @@ export function useServerStats({
}
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats when disabled or not connected
// Reset stats and fetch state when disabled or not connected
hasFetchedRef.current = false;
connectedAtRef.current = 0;
setStats({
cpu: null,
cpuCores: null,
@@ -175,10 +186,43 @@ export function useServerStats({
return;
}
// Initial fetch with a small delay to let the connection stabilize
const initialTimer = setTimeout(() => {
fetchStats();
}, 2000);
// Track when the connection became available for delay calculation
// (must be before the isVisible check so hidden tabs record connection time)
if (connectedAtRef.current === 0) {
connectedAtRef.current = Date.now();
}
if (!isVisible) {
return () => {
isMountedRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}
// Invalidate any in-flight request from a previous visible/hidden cycle
// so stale responses don't overwrite the reset network stats below.
fetchGenerationRef.current++;
// Fetch immediately when resuming from hidden, or with a delay on first connect.
// When resuming, reset delta-based network stats (both aggregate and per-interface)
// so the first sample doesn't show averaged-over-hidden-interval throughput.
if (hasFetchedRef.current) {
setStats(prev => ({
...prev,
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: prev.netInterfaces.map(iface => ({ ...iface, rxSpeed: 0, txSpeed: 0 })),
}));
}
// Skip the warmup delay if the connection has been established long enough
// (e.g., tab was hidden while connected and is now becoming visible).
const connectionAge = Date.now() - connectedAtRef.current;
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
@@ -192,7 +236,7 @@ export function useServerStats({
intervalRef.current = null;
}
};
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
}, [enabled, isSupportedOs, isConnected, isVisible, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {

View File

@@ -5,6 +5,22 @@ import type { RefObject } from "react";
type SearchMatchCount = { current: number; total: number } | null;
const SEARCH_DECORATIONS = {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
} as const;
const SEARCH_OPTIONS = {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: SEARCH_DECORATIONS,
} as const;
export const useTerminalSearch = ({
searchAddonRef,
termRef,
@@ -39,19 +55,7 @@ export const useTerminalSearch = ({
searchTermRef.current = term;
searchAddon.clearDecorations();
const found = searchAddon.findNext(term, {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
},
});
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
if (found) {
setSearchMatchCount({ current: 1, total: 1 });
@@ -68,38 +72,14 @@ export const useTerminalSearch = ({
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findNext(term, {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
},
});
return searchAddon.findNext(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleFindPrevious = useCallback((): boolean => {
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findPrevious(term, {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
},
});
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleCloseSearch = useCallback(() => {

View File

@@ -854,6 +854,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
charset: ctx.host.charset,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});

View File

@@ -1,6 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
@@ -353,6 +354,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
term.loadAddon(webLinksAddon);
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
logRenderer();
const appLevelActions = getAppLevelActions();

View File

@@ -69,6 +69,9 @@ export interface Host {
group?: string;
tags: string[];
os: 'linux' | 'windows' | 'macos';
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
deviceType?: 'general' | 'network';
identityFileId?: string; // Reference to SSHKey
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
password?: string;
@@ -617,6 +620,7 @@ export interface TerminalSession {
port?: number;
moshEnabled?: boolean;
shellType?: 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
// Serial-specific connection settings
serialConfig?: SerialConfig;
}

View File

@@ -454,6 +454,7 @@ function execViaRawPty(serialPort, command, options) {
trackForCancellation = null,
chatSessionId,
abortSignal,
encoding = "utf8", // Callers should pass the session's resolved encoding
} = options || {};
// Simple incrementing key for the cancellation map (no markers sent to device)
@@ -537,8 +538,8 @@ function execViaRawPty(serialPort, command, options) {
const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB
const onData = (data) => {
// Use latin1 to match the terminal display decoder in terminalBridge.cjs.
const chunk = data.toString("latin1");
// latin1 for serial ports (matches terminalBridge.cjs decoder); utf8 for SSH PTY streams.
const chunk = typeof data === "string" ? data : data.toString(encoding);
chunkCount++;
// Cancel the no-response fallback on first data
if (noResponseTimer) {

View File

@@ -888,9 +888,18 @@ function registerHandlers(ipcMain) {
return { ok: false, error: "Session not found" };
}
// Look up device type from metadata (set by renderer from Host.deviceType).
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
const meta = mcpServerBridge.getSessionMeta(sessionId, chatSessionId) || {};
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
// disables an interface on Cisco). Skip for serial sessions.
if (session.protocol !== "serial") {
// disables an interface on Cisco). Skip for network devices and serial sessions.
if (!isNetworkDevice) {
const safety = mcpServerBridge.checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
@@ -905,8 +914,22 @@ function registerHandlers(ipcMain) {
};
}
// Prefer PTY stream (visible in terminal)
const ptyStream = session.stream || session.pty || session.proc;
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaRawPty(ptyStream, command, {
timeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
});
}
// Prefer PTY stream (visible in terminal)
if (ptyStream && typeof ptyStream.write === "function") {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaPty(ptyStream, command, {
@@ -919,6 +942,11 @@ function registerHandlers(ipcMain) {
});
}
// Network devices require an interactive PTY for raw command execution.
if (isNetworkDevice) {
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
// Fallback: SSH exec channel (invisible to terminal)
const sshClient = session.sshClient || session.conn;
if (sshClient && typeof sshClient.exec === "function") {
@@ -939,6 +967,7 @@ function registerHandlers(ipcMain) {
timeoutMs: serialTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: session.serialEncoding || "utf8",
});
}
@@ -1903,7 +1932,7 @@ function registerHandlers(ipcMain) {
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
`Call get_environment first to discover available sessions and their IDs. ` +
`For normal shell commands, use terminal_execute so you receive command output. ` +
`For serial/raw sessions (network devices), commands are sent as-is without shell wrapping and exit codes are unavailable.]\n\n${prompt}`;
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`;
// Build message content: text + optional attachments
// ACP provider only supports image/* and audio/* inline via `type: "file"`.

View File

@@ -236,6 +236,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
username: s.username || "",
protocol: s.protocol || "",
shellType: s.shellType || "",
deviceType: s.deviceType || "",
connected: s.connected !== false,
});
}
@@ -491,6 +492,7 @@ function handleGetContext(params) {
username: meta.username || session.username || "",
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
});
}
@@ -501,6 +503,7 @@ function handleGetContext(params) {
"The available sessions may be remote hosts, local terminals, Mosh-backed shells, or serial port connections (network devices, embedded systems). " +
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
"Always prefer these tools over suggesting the user to do things manually.",
hosts,
hostCount: hosts.length,
@@ -519,6 +522,17 @@ function handleExec(params) {
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
// Look up device type from metadata (set by renderer from Host.deviceType).
const chatSessionId = params?.chatSessionId || null;
const meta = getSessionMeta(sessionId, chatSessionId) || {};
// Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs,
// so network device mode only applies to SSH and serial sessions.
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
// are meaningless on network device CLIs. Serial sessions skip the check because
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
@@ -530,7 +544,7 @@ function handleExec(params) {
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
// actually be interpreted even if sent to a serial-connected shell.
if (session.protocol !== "serial") {
if (!isNetworkDevice) {
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
@@ -547,6 +561,19 @@ function handleExec(params) {
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
// so shell-wrapped commands with markers would fail. Raw mode sends commands
// as-is with idle-timeout completion detection — same as serial sessions.
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
return execViaRawPty(ptyStream, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
});
}
// Prefer the interactive PTY so the user sees command/output in-session.
if (ptyStream && typeof ptyStream.write === "function") {
return execViaPty(ptyStream, command, {
@@ -557,6 +584,12 @@ function handleExec(params) {
});
}
// Network devices require an interactive PTY for raw command execution.
// If we got here, ptyStream wasn't writable — there's no usable channel.
if (isNetworkDevice) {
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
// Fallback: SSH exec channel (invisible to terminal).
// At this point ptyStream is not writable (already returned above if it was).
if (sshClient && typeof sshClient.exec === "function") {
@@ -572,6 +605,7 @@ function handleExec(params) {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: session.serialEncoding || "utf8",
});
}
@@ -657,6 +691,7 @@ module.exports = {
activePtyExecs,
cancelAllPtyExecs,
cancelPtyExecsForSession,
getSessionMeta,
cleanupScopedMetadata,
cleanup,
setMainWindowGetter,

View File

@@ -19,6 +19,20 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
let sessions = null;
let electronModule = null;
// Map user-facing charset names to Node.js StringDecoder/Buffer encoding names.
// Falls back to utf8 for unrecognized charsets (StringDecoder only supports a
// small set; for CJK encodings like GB18030/Big5 we'd need iconv-lite, which
// is out of scope for this change — utf8 is still the safer default).
function charsetToNodeEncoding(charset) {
if (!charset) return 'utf8';
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
if (normalized === 'ascii') return 'ascii';
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
return 'utf8';
}
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
@@ -513,16 +527,6 @@ async function startTelnetSession(event, options) {
resolve({ sessionId });
});
const charsetToNodeEncoding = (charset) => {
if (!charset) return 'utf8';
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
if (normalized === 'ascii') return 'ascii';
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
return 'utf8';
};
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
const telnetWebContentsId = event.sender.id;
@@ -762,11 +766,15 @@ async function startSerialSession(event, options) {
console.log(`[Serial] Connected to ${portPath}`);
const serialEncoding = charsetToNodeEncoding(options.charset);
const serialDecoder = new StringDecoder(serialEncoding);
const session = {
serialPort,
type: 'serial',
protocol: 'serial',
shellKind: 'raw',
serialEncoding,
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
@@ -782,8 +790,6 @@ async function startSerialSession(event, options) {
});
}
const serialDecoder = new StringDecoder('latin1');
serialPort.on('data', (data) => {
const decoded = serialDecoder.write(data);
if (decoded) {

View File

@@ -81,7 +81,7 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) {
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.';
}
// When skipBlocklist is true, the caller relies on the TCP bridge layer for
// session-aware blocklist checks (e.g. serial sessions skip shell patterns).
// session-aware blocklist checks (e.g. serial and network device sessions skip shell patterns).
if (!skipBlocklist && command) {
const safety = checkCommandSafety(command);
if (safety.blocked) {
@@ -198,7 +198,7 @@ server.resource(
// Tool: get_environment
server.tool(
"get_environment",
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, Mosh-backed shells, or serial port connections (network devices, embedded systems). Serial sessions have protocol 'serial' and shellType 'raw'. Call this first before executing commands.",
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, Mosh-backed shells, or serial port connections (network devices, embedded systems). Serial sessions have protocol 'serial' and shellType 'raw'. SSH sessions with deviceType 'network' are network equipment (Huawei VRP, Cisco IOS, etc.) using vendor CLIs instead of a standard shell. Call this first before executing commands.",
{},
async () => {
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
@@ -216,7 +216,7 @@ server.tool(
// Tool: terminal_execute
server.tool(
"terminal_execute",
"Execute a command on a Netcatty terminal session. For shell sessions, the command runs in the session's shell. For serial/raw sessions (network devices), the command is sent as-is without shell wrapping and exit codes are unavailable.",
"Execute a command on a Netcatty terminal session. For shell sessions, the command runs in the session's shell. For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable.",
{
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
command: z.string().describe("The command to execute in the target session."),
@@ -234,7 +234,7 @@ server.tool(
const parts = [];
if (result.stdout) parts.push(result.stdout);
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
// Serial/raw sessions return null exitCode (vendor CLIs have no exit codes)
// Serial/raw and network device sessions return null exitCode (vendor CLIs have no exit codes)
if (result.exitCode != null) {
parts.push(`[exit code: ${result.exitCode}]`);
}

2
global.d.ts vendored
View File

@@ -185,6 +185,7 @@ declare global {
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
charset?: string;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
@@ -733,6 +734,7 @@ declare global {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;

View File

@@ -391,6 +391,9 @@ body {
padding: 0 !important;
margin: 0 !important;
overflow-x: auto !important;
overflow-y: hidden !important;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
font-size: 0 !important; /* collapse whitespace text nodes */
}
@@ -399,11 +402,15 @@ body {
}
[data-streamdown="code-block"] pre {
display: block !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 12px 10px !important;
width: max-content !important;
min-width: 100% !important;
font-size: 12px !important;
line-height: 1.5 !important;
white-space: pre !important;
}

View File

@@ -39,6 +39,7 @@ export interface ExecutorContext {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
// Workspace info

View File

@@ -9,6 +9,7 @@ export interface SystemPromptContext {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
@@ -56,7 +57,7 @@ ${permissionRules}
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.
10. **Serial/raw sessions.** Sessions with \`protocol: serial\` and \`shell: raw\` are connected to network devices or embedded systems via serial port. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable for serial sessions. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
}
@@ -91,6 +92,7 @@ function buildHostList(
host.os ? `os: ${host.os}` : null,
host.username ? `user: ${host.username}` : null,
host.shellType ? `shell: ${host.shellType}` : null,
host.deviceType ? `deviceType: ${host.deviceType}` : null,
`status: ${status}`,
]
.filter(Boolean)

View File

@@ -66,7 +66,7 @@ function isObserver(mode: AIPermissionMode): boolean {
export async function executeTerminalExecute(
deps: ToolDeps,
args: { sessionId: string; command: string },
): Promise<ToolExecResult<{ stdout: string; stderr: string; exitCode: number }>> {
): Promise<ToolExecResult<{ stdout: string; stderr: string; exitCode: number | null }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionId, command } = args;
@@ -79,11 +79,14 @@ export async function executeTerminalExecute(
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode to execute commands.' };
}
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
// disables an interface on Cisco). Skip for serial sessions. The bridge layer
// (handleExec / netcatty:ai:exec) also has its own session-aware check.
// disables an interface on Cisco). Skip for serial and network device sessions.
// The bridge layer (handleExec / netcatty:ai:exec) also has its own session-aware check.
const resolved = resolveContext(context);
const targetSession = resolved.sessions.find(s => s.sessionId === sessionId);
if (targetSession?.protocol !== 'serial') {
const proto = targetSession?.protocol || '';
const isSshOrSerial = proto === 'ssh' || proto === 'serial';
const isNetworkDevice = proto === 'serial' || (targetSession?.deviceType === 'network' && isSshOrSerial);
if (!isNetworkDevice) {
const safety = checkCommandSafety(command, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
@@ -98,13 +101,16 @@ export async function executeTerminalExecute(
if (result.stderr) parts.push(`Stderr:\n${result.stderr}`);
return { ok: false, error: parts.join('\n\n') };
}
// Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge
// Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge.
// Network device / serial sessions return exitCode: null because vendor CLIs don't expose
// exit codes. Preserve null so the model knows exit status is unavailable rather than
// seeing a misleading 0 (success) or -1 (failure).
return {
ok: true,
data: {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.exitCode ?? -1,
exitCode: isNetworkDevice ? (result.exitCode ?? null) : (result.exitCode ?? -1),
},
};
}
@@ -122,6 +128,7 @@ export function executeWorkspaceGetInfo(
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
}> {
@@ -139,6 +146,7 @@ export function executeWorkspaceGetInfo(
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
deviceType: s.deviceType,
connected: s.connected,
})),
},

View File

@@ -858,6 +858,19 @@ export class CloudSyncManager {
}
}
/**
* Reset provider status to disconnected without tearing down existing connections.
* Used when an auth attempt is cancelled/fails — avoids destroying a previously
* working connection if the user was re-authenticating.
*/
resetProviderStatus(provider: CloudProvider): void {
// Only reset if currently 'connecting' — don't drop an already authenticated
// provider back to 'disconnected' (e.g., if auth succeeded but sync init failed).
if (this.state.providers[provider]?.status === 'connecting') {
this.updateProviderStatus(provider, 'disconnected');
}
}
/**
* Disconnect a provider
*/

7
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -6709,6 +6710,12 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",

View File

@@ -53,6 +53,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",