Compare commits

...

20 Commits

Author SHA1 Message Date
陈大猫
2bf2220d0b fix: open quick-add snippet modal in place instead of navigating (#657)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The previous "+" flow in ScriptsSidePanel switched the active tab to
Vault and jumped to the Snippets section, which ripped the user out
of their current terminal context — exactly what the feature was
supposed to avoid.

Replace the cross-panel navigation flow with a lightweight modal
dialog mounted at the App root:

- New component QuickAddSnippetDialog renders over everything and
  owns its own form state. Fields: label, command (multi-line), and
  package (combobox with allowCreate).
- App.tsx mounts the dialog globally and wires it to updateSnippets /
  updateSnippetPackages. No prop drilling through TerminalLayer.
- ScriptsSidePanel still dispatches the same netcatty:snippets:add
  window event; the dialog listens for it and opens in place.
- Reverted the navigateToSection / pendingSnippetAdd / openAddTrigger
  plumbing in App.tsx, VaultView, and SnippetsManager.

Advanced fields (targets, shortkey, tags) can still be set later
via the full Snippets manager. Cmd/Ctrl+Enter saves from any field.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:10:34 +08:00
陈大猫
683756324e feat: add "new snippet" button in terminal ScriptsSidePanel (#641) (#656)
* feat: add "new snippet" button in terminal ScriptsSidePanel (#641)

Previously, adding a new snippet required navigating back to the main
Snippets section from the Vault view. This adds a "+" button in the
search header of the terminal-side ScriptsSidePanel that jumps
directly into the snippet edit flow.

Flow:
- ScriptsSidePanel "+" → dispatches window event `netcatty:snippets:add`
- App.tsx listens → switches activeTab to vault, navigates to Snippets
  section, and bumps a monotonic `openSnippetAddTrigger` state
- VaultView forwards the trigger to SnippetsManager
- SnippetsManager watches the trigger and opens its add panel when
  the value changes (uses a ref to ignore unrelated remounts)

Closes #641

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

* fix: switch add-snippet flow to one-shot pending flag

Codex review pointed out a real bug with the monotonic trigger approach:
when SnippetsManager mounts for the first time with openAddTrigger already
non-zero (the common "+ clicked from terminal while not on Snippets section"
path), the last-seen-trigger ref is initialized to the current value and
the useEffect immediately returns early, so the add panel never opens.

Switch to a cleaner one-shot pending flag:
- App.tsx holds pendingSnippetAdd: boolean + handlePendingSnippetAddHandled
- VaultView forwards pendingSnippetAdd + onPendingSnippetAddHandled
- SnippetsManager opens the add panel on every transition to pendingAdd=true,
  then clears the flag via onPendingAddHandled, so subsequent renders and
  plain remounts are no-ops

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

* fix: move useCallback above early return in ScriptsSidePanel

React's rules-of-hooks require all hooks to be called unconditionally.
The new handleAddSnippet useCallback was placed after the
`if (!isVisible) return null;` guard, which tripped eslint.

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-04-09 01:58:34 +08:00
陈大猫
80fbf0da2f feat: add data-section hooks for Custom CSS targeting (#642) (#655)
Custom CSS already exists in Settings → Appearance, but major UI
components use only Tailwind utility classes, making it hard for
users to reliably target regions in their custom styles.

This adds stable `data-section="..."` attributes on the root element
of the most commonly customized UI regions so users can write selectors
like `[data-section="snippets-panel"] { font-size: 14px !important; }`
without depending on implementation details.

Instrumented regions:
- snippets-panel (ScriptsSidePanel)
- host-details-panel (HostDetailsPanel via AsidePanel dataSection prop)
- group-details-panel (GroupDetailsPanel)
- serial-host-details-panel (SerialHostDetailsPanel)
- ai-chat-panel (AIChatSidePanel)
- vault-view / vault-sidebar / vault-main / vault-hosts-header / vault-host-list (VaultView)
- terminal-workspace / terminal-workspace-sidebar (TerminalLayer)
- top-tabs (TopTabs — also keeps existing data-top-tabs-root)

Also updated the Custom CSS description and placeholder in both
English and Chinese to list available hooks and show a working
example (snippet panel font-size override).

Closes #642

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:38:50 +08:00
陈大猫
556a14178c fix: prevent host details panel from being clipped on narrow windows (#653)
When the host details / new-host aside panel is open, narrow windows
could clip the panel content because the main area lacked min-w-0 and
the window had no minimum size.

- Add min-w-0 to the main area so flexbox can shrink the host list
  portion when the window narrows, keeping the 420px panel fully visible
- Set the BrowserWindow minWidth/minHeight to 1100x640 so the user
  cannot drag the window narrower than what the panel + sidebar +
  host list need to render comfortably
- Clamp previously saved window dimensions to the new minimum on launch
- Animate the New Host split button and the Terminal / Serial buttons
  to collapse with a 200ms transition when the host panel is open,
  freeing horizontal space and hiding controls that would be no-ops

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:55 +08:00
Eric Chan
7e566efe9c Add push-style host details panels (#649)
Refs: https://github.com/binaricat/Netcatty/issues/640
2026-04-08 16:42:32 +08:00
Eric Chan
1d2489b02c feat: support long-running AI terminal jobs (#647)
* Add background terminal jobs for long AI commands

* Bound background job output buffering

* Fix long-running terminal job polling and stop behavior

* Fix terminal job final output and stopping retention

* Wait for PTY stop confirmation before cancelling

* fix: address codex review findings in PTY job refactor

- [P1] Use last occurrence of start marker to skip echoed wrapper command,
  preventing control markers from leaking into stdout
- [P1] Add wall-clock timeout for foreground PTY execution so commands that
  print continuously still get terminated at the configured limit
- [P2] Add hard deadline for cancellation so jobs that ignore Ctrl+C are
  force-finished after 30s instead of staying stuck in "stopping" forever

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

* fix: address round-2 codex review findings

- [P1] Use visibleOutput for background job completion to keep offsets
  consistent with polling, preventing output loss when raw buffer
  (with ANSI codes) truncates earlier than the visible buffer
- [P2] Clarify system prompt that terminal_start requires PTY-backed
  sessions, so exec-only SSH sessions are not incorrectly routed

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

* fix: address round-3 codex review findings

- [P1] Always strip markers from visibleOutput in background job finish
  to prevent end-marker lines leaking into terminal_poll results
- [P2] Correct terminal_execute timeout guidance from ~2min to ~60s to
  match the actual default commandTimeoutMs (60000)

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

* fix: address round-4 codex review findings

- [P1] Delay session lock release when cancel is forced (process may
  still be running) to prevent sending commands into a busy shell
- [P2] Move scope validation before pendingSessionWriteApprovals so
  out-of-scope requests fail fast without blocking the write lock
- [P2] Add session scope checks to handleJobPoll and handleJobStop
  so chats that lose access cannot read output or cancel jobs

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

* fix: address round-5 codex review findings

- [P1] Strip marker lines before they enter the bounded visible buffer
  so they never occupy space or leak as partial fragments on truncation
- [P2] Never release session lock after forced cancellation since the
  previous process may still be attached to the PTY

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

* fix: address round-6 codex review findings

- [P2] Buffer incomplete marker lines across PTY chunks to prevent
  partial marker fragments from leaking into visible output
- [P1] Release session lock after 60s delay on forced cancel as
  compromise between safety and permanent lock
- [P2] Enforce session scope checks on jobPoll/jobStop for both
  dynamic (chatSessionId) and static (NETCATTY_MCP_SESSION_IDS) modes

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

* fix: address round-7 codex review findings

- [P2] validateSessionScope now accepts explicit scopedSessionIds so
  static MCP scope mode is enforced for jobPoll/jobStop too
- [P2] Apply per-session execution lock to netcatty:ai:exec IPC path
  so it cannot race with active background jobs on the same session

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

* fix: address round-8 codex review findings

- [P1] Make wall-clock timeout opt-in via enforceWallTimeout flag,
  enabled only for MCP terminal_execute path. Catty Agent's
  netcatty:ai:exec keeps the inactivity-based timeout since it has
  no terminal_start fallback for long-running streaming commands
- [P2] Always allow handleJobStop regardless of session scope so
  the per-session execution lock can always be released after
  workspace membership changes

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

* fix: address round-9 codex review findings

- [P1] Enable enforceWallTimeout for netcatty:ai:exec to match the
  pre-PR behavior (hard wall-clock deadline). Without this, tail -f
  or verbose builds would hold the session lock indefinitely
- [P2] Treat explicit scopedSessionIds=[] as no access rather than
  falling through to global scope, matching handleGetContext's
  documented behavior

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

* fix: address round-10 codex review findings

- [P2] Add bounded startup deadline (30s) for the start marker arrival
  even when wall-clock timeout is disabled. Prevents background jobs
  from hanging indefinitely on already-chatty PTY sessions
- [P3] Use job-specific marker (not generic __NCMCP_) when stripping
  marker lines, so user output containing __NCMCP_ is preserved

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

* fix: address round-11 codex review findings

- [P2] Skip the 30s startup timeout for foreground execViaPty paths.
  It now applies only when maxBufferedChars > 0 (background jobs),
  so foreground commands queued behind a busy shell can wait
- [P2] Return empty stdout from getSnapshot() before the start marker
  arrives, so an early poll cannot advance nextOffset past pre-start
  PTY noise that gets discarded once the real command begins

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

* fix: address round-12 codex review findings

- [P1] Treat empty chat scopes as no access in validateSessionScope:
  if a chat has explicit scoped metadata (even []), enforce strictly
  rather than falling through to fallback/global scope
- [P2] Re-add session scope check in handleJobStop for static MCP
  clients (scopedSessionIds), while still allowing dynamic chat-scoped
  callers to always stop their own jobs even after scope changes

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

* fix: address round-13 codex review findings

- [P2] getScopedJob now requires the caller to present the job's
  chatSessionId. Unscoped/static callers cannot reach into another
  chat's background jobs even if they learn the jobId
- [P2] Stop button no longer cancels terminal_start background jobs.
  They are intentionally long-running, so killing them on every
  per-response stop defeats the purpose of the feature. Cleanup on
  chat deletion (cleanupScopedMetadata) is preserved

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

* fix: address round-14 codex review findings

- [P1] terminal_start jobs no longer registered in activePtyExecs so
  ACP "Stop" / cancelPtyExecsForSession does not kill them. They are
  still managed via terminal_stop and the per-session execution lock
- [P1] Remove enforceWallTimeout from netcatty:ai:exec since Catty
  Agent has no terminal_start fallback for long-running commands.
  Inactivity timeout still catches genuinely hung processes
- [P2] Forced-cancelled jobs stay in "stopping" (completed=false)
  until the 60s lock grace period ends, so callers don't see the
  job as completed while the session is still locked

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

* fix: address round-15 codex review findings

- [P2] Allow netcatty/jobStop to bypass the chat-cancelled gate so
  users can stop terminal_start jobs even after ACP "Stop" was pressed
- [P2] Mark non-zero exit codes as failed (not completed) so callers
  don't have to special-case exitCode against status
- [P2] Pre-start cancel: clear startup timer in requestCancel and
  detect prompt return on preStartOutput so a queued job that gets
  cancelled resolves as "Cancelled", not "startup timed out"

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

* fix: address round-16 codex review findings

- [P2] Cap preStartOutput for background jobs at maxBufferedChars so
  noisy idle PTYs cannot accumulate megabytes before the start marker
  arrives or the startup timeout fires
- [P2] On forced cancel, immediately release the session lock and
  mark the job as cancelled. The error message clearly states that
  the process may still be running, and the caller sees completed=true
  exactly when the lock is no longer held — consistent semantics

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

* fix: address round-17 codex review findings

- [P2] Disable prompt-suffix completion fallback for background jobs.
  Long-running commands often print prompt-like text (nested shells,
  ssh, sudo -s, REPLs) and would otherwise be misdetected as completed.
  Background jobs rely strictly on the end marker
- [P2] consumeVisibleText now treats \\r as a carriage return that
  resets the current line, so progress bars (npm, docker pull, curl)
  collapse to the latest frame instead of accumulating every redraw

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

* fix: address round-18 codex review findings

- [P2] Pre-start cancel on sessions without a tracked idle prompt now
  gets a 2s fallback to finish as Cancelled, instead of waiting the
  full forced-cancel window for an end marker that will never arrive
- [P3] Move session-scope validation before the busy-session check so
  out-of-scope callers cannot probe the existence/activity of foreign
  sessions via busy-state error messages

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

* fix: address round-19 codex review findings

- [P1] Re-enable prompt-suffix completion fallback for background
  jobs but with a longer 10s delay so nested shells / REPLs have
  time to print past their initial prompt before the recheck
- [P2] Carriage returns now collapse progress redraws across PTY
  chunks: \\r is preserved through consumeVisibleText and
  applyCarriageReturns erases the trailing line of visibleOutput
  when a chunk starts with \\r. Verified with a fake PTY that
  emits "10%" then "\\r20%" then "\\r30%\\n" — final output is "30%"

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

* fix: address round-20 codex review findings

- [P1] Disable prompt-suffix completion fallback for background jobs.
  Commands that open child shells with the same prompt as the parent
  (bash, zsh, sudo -s, ssh) would otherwise be reported as completed
  while the child is still running. Background jobs rely strictly on
  the end marker, with their long timeout and explicit terminal_stop
- [P2] Track a monotonic visibleHighWatermark so polling nextOffset
  cannot move backwards across CR redraws. serializeBackgroundJob now
  returns the latest visible frame when the caller's offset has been
  passed by a redraw, instead of returning empty stdout permanently
- [P3] Buffer trailing lines that contain the constant __NCMCP_
  prefix (not just the full random marker token) so PTY chunk
  boundaries that split the marker mid-token cannot leak _E:0 noise

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

* fix: address round-21 codex review findings

- [P2] Foreground execs now also get a hard startup deadline (using
  the configured timeoutMs as the limit). Background jobs use a
  fixed 30s. Without this, an already-chatty PTY would let onData
  re-arm the inactivity timer forever before _S arrives
- [P2] finish() now uses the monotonic visibleHighWatermark for
  totalOutputChars on completion, so the final poll's nextOffset
  cannot regress relative to earlier polls after CR redraws

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

* fix: address round-22 codex review findings

- [P2] cleanupScopedMetadata now also calls clearPendingApprovals so
  in-flight approval requests resolve immediately. Otherwise a chat
  deleted while an approval was pending would leave the per-session
  write lock held until the 5-minute approval timeout expires
- [P2] Allow netcatty/jobStop in observer mode so users can stop
  long-running terminal_start jobs that were launched before they
  switched to observer mode

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

* fix: address round-23 codex review finding

- [P2] Apply \\r as a "deferred" carriage return: park the cursor at
  the start of the line but defer erasure until the next character
  arrives. This preserves the latest visible frame for commands like
  printf '10%%\\r'; sleep; printf '20%%\\r' that pause between
  redraws, while still collapsing continuous progress redraws to a
  single frame. Verified: snapshots now show '40%' and '50%' instead
  of empty stdout

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

* fix: address round-24 codex review findings

- [P1] Re-enable prompt fallback for background jobs with a 30s
  delay so commands open child shells / REPLs have time to print
  past their initial prompt before the recheck. This is the third
  time codex has flip-flopped on this — 30s is the compromise
- [P2] Pass chatSessionId to execViaChannel in handleExec so
  cancelPtyExecsForSession can interrupt SSH exec-channel commands
  scoped to the originating chat

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

* fix: address round-25 codex review finding

- [P1] Stop in-place CR collapsing in visibleOutput. The collapsed
  buffer made polling offsets non-monotonic and could drop finalized
  lines after a CR rewrite. Now visibleOutput stores raw bytes (with
  \\r dropped at consumeVisibleText to keep the buffer simple), the
  256KB cap naturally bounds progress-bar accumulation, and slice
  semantics work correctly across all redraw patterns. Consumers
  that want a "collapsed view" can post-process

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

* fix: address round-26 codex review findings

- [P2] Carriage returns are now preserved in the raw buffer and
  collapsed at serialize time in collapseCarriageReturns. This keeps
  monotonic offsets in the buffer while polled output shows the
  latest progress frame. A trailing \\r leaves existing content
  intact (deferred erasure semantics)
- [P2] netcatty/jobStop now bypasses the confirm-mode approval gate
  so a runaway terminal_start job can always be interrupted, even
  when the renderer is unavailable
- [P3] requestCancel's one-shot timers (2s pre-start, 150ms reinforce,
  30s force-finish) are now tracked and cleared in finish() so they
  cannot keep the Node event loop alive after the job has resolved

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-04-08 16:39:21 +08:00
陈大猫
5ad3d0ce32 fix: prevent crash when codex-acp binary is not found (#648)
* fix: prevent crash when codex-acp binary is not found (#645)

When codex-acp is not installed, resolveCodexAcpBinaryPath returned the
bare binary name as a fallback. This caused createACPProvider to spawn a
non-existent process, emitting an async ENOENT error that crashed the app.

Return null instead of the bare name and guard all createACPProvider call
sites so the error is handled gracefully.

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

* fix: install cross-platform codex-acp binaries in CI build

macOS and Windows CI builds produce both arm64 and x64 packages, but
npm ci only installs optional dependencies for the host platform. This
means the codex-acp native binary for the other architecture is missing
from the packaged app, causing ENOENT crashes for users on the
non-host architecture.

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

* fix: add --force to bypass cpu/os constraints for cross-arch install

The platform-specific codex-acp packages declare cpu/os constraints in
their package.json, so npm refuses to install the non-host-arch binary
with EBADPLATFORM. Use --force to bypass this check.

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-04-08 10:53:27 +08:00
bincxz
edf013164b fix: limit recently connected hosts to 6
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:47 +08:00
陈大猫
504b576e1c fix: stop deduplicating pinned/recent hosts from main host list (#632) (#636)
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.

Closes #632

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:53:46 +08:00
Leo Pan
890abd1c4c Fix/terminal clear preserve scrollback (#633)
* fixd:issure #622

* fix: use baseY instead of viewportY for active screen row count

When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.

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

* fix: use term.clear() for local clear to preserve prompt line

The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.

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

* fix: preserve both scrollback and prompt in local clear

term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.

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

---------

Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:03:39 +08:00
陈大猫
0827dd416f fix: truncate long command text in snippet list to prevent layout overflow (#628) (#630)
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
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover

Closes #628

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +08:00
陈大猫
24df4b6548 fix: support CSV password import and save password in keyboard-interactive auth (#629)
* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

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

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

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

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

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

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

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

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

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

* fix: only skip formula guard for data rows, not header row

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-04-05 14:39:39 +08:00
陈大猫
7db4b18cce fix: add missing props destructuring in HostTreeView causing white screen (#625) (#626)
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
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.

Closes #625

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:38:15 +08:00
陈大猫
844c55e99d fix: sync built-in editor theme with terminal theme in immersive mode (#623) (#624)
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.

- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
  them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
  a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:40 +08:00
陈大猫
778b43ceff fix: reset mouse tracking on start over to prevent escape sequence leak (#616) (#621)
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.

Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.

Closes #616

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:03:04 +08:00
陈大猫
6b2e5041d2 fix: sort default shell to top in quick switcher (#613) (#620)
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.

Closes #613

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:46 +08:00
陈大猫
1464cba6da feat: add xterm-container class for custom CSS bottom spacing (#614) (#619)
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.

Example custom CSS:
  .xterm-container { bottom: 10px !important; }

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:26 +08:00
陈大猫
d74d9e28a0 fix: split shortcut in workspace panes and host delete form freeze (#612) (#618)
* fix: split shortcut in workspace panes and host delete form freeze (#612)

Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.

Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.

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

* fix: Shift+right-click context menu and split content loss (#612)

Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.

Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.

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

* fix: guard host-deletion cleanup against unsaved duplicates

The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.

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

* fix: check previous host IDs before updating ref in deletion cleanup

Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.

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

* fix: open context menu on first Shift+right-click

Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.

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

* fix: fallback to first live pane when workspace focus is stale

When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.

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

* fix: validate focusedSessionId against live workspace panes

focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.

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-04-03 14:38:02 +08:00
陈大猫
32b74f4fea fix: persist sidebar appearance overrides for quick-connect hosts (#611)
* fix: persist sidebar appearance overrides for quick-connect hosts

Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:

1. onUpdateHost only updated existing entries (map), never inserted —
   change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
   undefined for quick-connect hosts — fall back to focusedHost.

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

* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts

Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.

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

* fix: use primary font only in document.fonts.check to fix bold weight fallback

document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.

Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.

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

* fix: normalize WebGL fontWeight rendering after terminal connection

Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.

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

* fix: use global settings for quick-connect host appearance changes

Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.

Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.

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

* fix: address code review findings

- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
  (not just fontWeight) so all appearance changes on ephemeral hosts
  update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
  ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser

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-04-03 13:52:26 +08:00
Eric Chan
f284fb0505 Refine host group drop feedback (#617) 2026-04-03 12:15:07 +08:00
38 changed files with 2363 additions and 470 deletions

View File

@@ -43,6 +43,21 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
fi
- name: Set version
shell: bash
run: |

52
App.tsx
View File

@@ -32,6 +32,7 @@ import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
@@ -722,6 +723,7 @@ function App({ settings }: { settings: SettingsState }) {
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
@@ -736,14 +738,29 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
@@ -969,32 +986,32 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'splitHorizontal': {
// Split current terminal horizontally (top/bottom)
const currentId = activeTabStore.getActiveTabId();
// Check if it's a standalone session or we're in a workspace
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
// Split current terminal vertically (left/right)
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
@@ -1522,6 +1539,17 @@ function App({ settings }: { settings: SettingsState }) {
})}
</div>
{/* Global "quick add snippet" dialog, triggered by the
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
<QuickAddSnippetDialog
snippets={snippets}
packages={snippetPackages}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
{isQuickSwitcherOpen && (
<Suspense fallback={null}>
<LazyQuickSwitcher

View File

@@ -237,9 +237,9 @@ const en: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -529,6 +529,7 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
@@ -1643,10 +1644,7 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',

View File

@@ -220,9 +220,10 @@ const zhCN: Messages = {
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
@@ -349,6 +350,7 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -1650,10 +1652,7 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',

View File

@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
@@ -174,6 +175,7 @@ export function useImmersiveMode({
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);

View File

@@ -775,7 +775,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
if (!isVisible) return null;
return (
<div className="flex flex-col h-full bg-background">
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector

View File

@@ -39,6 +39,7 @@ import {
import {
AsidePanel,
AsidePanelContent,
type AsidePanelLayout,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
@@ -63,6 +64,7 @@ interface GroupDetailsPanelProps {
terminalFontSize: number;
onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
@@ -76,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
terminalFontSize,
onSave,
onCancel,
layout = "overlay",
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
@@ -351,6 +354,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -368,6 +372,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -395,6 +400,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -411,6 +417,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -426,7 +433,9 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
open={true}
onClose={onCancel}
width="w-[380px]"
dataSection="group-details-panel"
title={t("vault.groups.details")}
layout={layout}
actions={
<Button
variant="ghost"

View File

@@ -51,6 +51,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -100,6 +101,7 @@ interface HostDetailsPanelProps {
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
onCreateTag?: (tag: string) => void; // Callback to create a new tag
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
layout?: AsidePanelLayout;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -118,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateGroup,
onCreateTag,
groupDefaults,
layout = "overlay",
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
@@ -502,6 +505,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onSave={handleCreateGroup}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -514,6 +518,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -531,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -559,6 +565,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
/>
);
}
@@ -576,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -614,6 +622,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel}
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
/>
);
}
@@ -624,6 +633,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true}
onClose={onCancel}
width="w-[420px]"
layout={layout}
dataSection="host-details-panel"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}

View File

@@ -36,6 +36,8 @@ interface HostTreeViewProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
interface TreeNodeProps {
@@ -61,6 +63,8 @@ interface TreeNodeProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
@@ -87,6 +91,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -140,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
@@ -147,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
@@ -242,6 +258,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -425,9 +443,11 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -548,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -578,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
)}
</div>
);
};
};

View File

@@ -4,7 +4,7 @@
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request]);
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);

View File

@@ -0,0 +1,185 @@
/**
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
* App root and triggered by the `netcatty:snippets:add` window event.
*
* Intentionally minimal: label + command + package only. Advanced fields
* (target hosts, shortkey, tags) can be set later via the full Snippets
* manager. This keeps the user in their terminal context instead of
* navigating to the Vault view just to add a command.
*/
import { Package } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { Snippet } from '../domain/models';
import { Button } from './ui/button';
import { Combobox } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
export interface QuickAddSnippetDialogProps {
snippets: Snippet[];
packages: string[];
onCreateSnippet: (snippet: Snippet) => void;
onCreatePackage?: (packagePath: string) => void;
}
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
snippets,
packages,
onCreateSnippet,
onCreatePackage,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
// Listen for the global "add snippet" request dispatched by the
// terminal-side ScriptsSidePanel + button. We reset form state on
// every open so stale input from a previous cancel does not leak.
useEffect(() => {
const handler = () => {
setLabel('');
setCommand('');
setPackagePath('');
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
return () => window.removeEventListener('netcatty:snippets:add', handler);
}, []);
// Auto-focus the label input once the dialog renders, so the user can
// start typing immediately after clicking the + button.
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
return () => window.clearTimeout(id);
}, [open]);
// Derive combobox options from the union of existing packages (from
// props) and any package path referenced by an existing snippet, so
// the user can reuse anything they see in the main snippets view.
const packageOptions = useMemo(() => {
const set = new Set<string>();
for (const p of packages) {
if (p) set.add(p);
}
for (const s of snippets) {
if (s.package) set.add(s.package);
}
return Array.from(set).sort().map((value) => ({ value, label: value }));
}, [packages, snippets]);
const canSave = label.trim().length > 0 && command.trim().length > 0;
const handleSave = useCallback(() => {
if (!canSave) return;
const trimmedPackage = packagePath.trim();
// If the user typed a brand new package name, surface it to the parent
// so it can be added to the user's package list alongside the snippet.
if (trimmedPackage && !packages.includes(trimmedPackage)) {
onCreatePackage?.(trimmedPackage);
}
onCreateSnippet({
id: crypto.randomUUID(),
label: label.trim(),
command, // preserve whitespace in multi-line commands
tags: [],
package: trimmedPackage || '',
targets: [],
});
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
e.preventDefault();
handleSave();
}
},
[canSave, handleSave],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.empty.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-label" className="text-xs">
{t('snippets.field.description')}
</Label>
<Input
id="quick-add-snippet-label"
ref={labelInputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="quick-add-snippet-command" className="text-xs">
{t('snippets.field.scriptRequired')}
</Label>
<Textarea
id="quick-add-snippet-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="echo hello"
className="min-h-[120px] font-mono text-xs"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1.5">
<Package size={12} /> {t('snippets.field.package')}
</Label>
<Combobox
value={packagePath}
onValueChange={setPackagePath}
options={packageOptions}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate
onCreateNew={setPackagePath}
createText={t('snippets.field.createPackage')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default QuickAddSnippetDialog;

View File

@@ -89,11 +89,13 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const discoveredShells = useDiscoveredShells();
const filteredShells = useMemo(() => {
if (!query.trim()) return discoveredShells;
const q = query.toLowerCase();
return discoveredShells.filter(
(s) => s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
);
const list = !query.trim()
? discoveredShells
: discoveredShells.filter(
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
);
// Default shell first
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
}, [discoveredShells, query]);
// Get hotkey display strings

View File

@@ -5,7 +5,7 @@
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
// the "add" panel pre-opened, so the user does not have to leave the
// terminal to jump back and click "New Snippet".
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
}, []);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<button
type="button"
onClick={handleAddSnippet}
title={t('snippets.action.newSnippet')}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</div>
{/* Breadcrumb */}

View File

@@ -17,6 +17,7 @@ import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
} from './ui/aside-panel';
interface SerialPort {
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
groups = [],
onSave,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
@@ -164,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
layout={layout}
dataSection="serial-host-details-panel"
>
<AsidePanelContent>
{/* Label */}

View File

@@ -18,6 +18,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { SortDropdown, SortMode } from './ui/sort-dropdown';
import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -951,8 +952,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
return (
<TooltipProvider delayDuration={300}>
<div className="h-full flex gap-3 relative">
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-2">
{/* Search box */}
@@ -1059,7 +1061,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1079,11 +1081,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
</div>
@@ -1114,7 +1116,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1126,15 +1128,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
{snippet.command}
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
@@ -1254,6 +1263,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{/* Right Panel */}
{renderRightPanel()}
</div>
</TooltipProvider>
);
};

View File

@@ -47,7 +47,7 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
@@ -256,6 +256,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -329,6 +330,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
// look different from dynamically-set ones. After text appears on screen (status
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
useEffect(() => {
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
fontWeightFixupDoneRef.current = true;
const timer = setTimeout(() => {
if (!termRef.current) return;
// Re-read the current weight at fire time to avoid stale closures
const w = termRef.current.options.fontWeight;
if (w === 'normal' || w === 400) return;
termRef.current.options.fontWeight = 'normal';
termRef.current.options.fontWeight = w;
}, 200);
return () => clearTimeout(timer);
}, [status]);
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
@@ -959,7 +977,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
@@ -1046,7 +1064,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
@@ -1117,10 +1135,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
}, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
@@ -1406,6 +1430,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
// Reset terminal state: disable mouse tracking modes and clear screen so
// stale SGR mouse sequences don't leak into the new session as text input.
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
termRef.current.reset();
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
@@ -1988,7 +2016,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div
ref={containerRef}
className="absolute inset-x-0 bottom-0"
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,

View File

@@ -1379,6 +1379,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
// sidebar appearance changes should update global settings, not per-host overrides.
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !hostMap.has(focusedHost.id);
}, [focusedHost, isFocusedHostLocal, hostMap]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
? themePreview.themeId
@@ -1525,14 +1532,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
themeCommitTimerRef.current = setTimeout(() => {
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
return;
}
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
});
}, 160);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (themeCommitTimerRef.current) {
@@ -1540,64 +1547,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
clearTerminalPreviewVars(previewTargetSessionId);
setThemePreview({ targetSessionId: null, themeId: null });
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
});
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
// Patch only fontWeight fields on the raw (un-merged) host to avoid flattening group defaults
// Prefer raw (un-merged) host to avoid flattening group defaults
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostLocal, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostLocal, onUpdateHost, hostMap]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
@@ -1812,7 +1819,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (!activeWorkspace || !isFocusMode) return null;
return (
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
<div
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
data-section="terminal-workspace-sidebar"
>
{/* Header with view toggle */}
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">
@@ -1883,6 +1893,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
data-section="terminal-workspace"
style={{
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',

View File

@@ -125,12 +125,38 @@ const hslToHex = (hslString: string): string => {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
@@ -163,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setBgColor(getBackgroundColor());
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import {
AsidePanel,
AsidePanelContent,
type AsidePanelLayout,
} from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList';
@@ -13,6 +14,7 @@ interface ThemeSelectPanelProps {
onClose: () => void;
onBack?: () => void;
showBackButton?: boolean;
layout?: AsidePanelLayout;
}
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
@@ -22,6 +24,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onClose,
onBack,
showBackButton = true,
layout = 'overlay',
}) => {
return (
<AsidePanel
@@ -30,6 +33,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
title="Select Color Theme"
showBackButton={showBackButton}
onBack={onBack}
layout={layout}
>
<AsidePanelContent className="p-0">
<ScrollArea className="h-full">

View File

@@ -765,6 +765,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
data-section="top-tabs"
className="relative w-full bg-secondary app-drag"
style={{
...dragRegionNoSelect,

View File

@@ -101,6 +101,10 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -222,7 +226,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
false,
);
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
const [dragOverDropTarget, setDragOverDropTarget] = useState<DropTarget | null>(null);
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -237,6 +243,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}, [navigateToSection, onNavigateToSectionHandled]);
useEffect(() => {
return () => {
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
};
}, []);
// View mode, sorting, and tag filter state
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
@@ -253,6 +267,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [editingHost, setEditingHost] = useState<Host | null>(null);
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
// Close host panel if the host being edited was deleted.
// Track previous host IDs so we only close for actual deletions, not for
// unsaved new/duplicated hosts whose IDs were never in the hosts array.
const knownHostIdsRef = useRef(new Set(hosts.map(h => h.id)));
useEffect(() => {
const currentIds = new Set(hosts.map(h => h.id));
// Check against previous IDs before updating the ref
if (editingHost && knownHostIdsRef.current.has(editingHost.id) && !currentIds.has(editingHost.id)) {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}
knownHostIdsRef.current = currentIds;
}, [hosts, editingHost]);
// Group panel state
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
@@ -928,19 +957,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return filtered
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
.slice(0, 20);
.slice(0, 6);
}, [hosts, selectedGroupPath, search, selectedTags]);
// IDs of hosts already shown in Pinned/Recent sections at root level,
// so the main host list can exclude them to avoid duplicates.
const pinnedRecentIds = useMemo(() => {
const ids = new Set<string>();
for (const h of pinnedHosts) ids.add(h.id);
if (showRecentHosts) {
for (const h of recentHosts) ids.add(h.id);
}
return ids;
}, [pinnedHosts, recentHosts, showRecentHosts]);
// No longer deduplicate pinned/recent hosts from the main list,
// so hosts always appear in their groups regardless of pinned/recent status.
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
@@ -1421,9 +1443,46 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, [managedSources]);
const isHostsSectionActive = currentSection === "hosts";
const hasHostsSidePanel =
isHostsSectionActive &&
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
const splitViewGridStyle = hasHostsSidePanel
? {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 220px), 280px))",
justifyContent: "start" as const,
}
: undefined;
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === "root") return true;
return a.path === b.path;
}, []);
const pulseDropTarget = useCallback((target: DropTarget) => {
setConfirmedDropTarget(target);
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
dropTargetPulseTimeoutRef.current = window.setTimeout(() => {
setConfirmedDropTarget((current) => (isSameDropTarget(current, target) ? null : current));
dropTargetPulseTimeoutRef.current = null;
}, 900);
}, [isSameDropTarget]);
const setGroupDragOverDropTarget = useCallback((path: string | null) => {
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
@@ -1450,7 +1509,23 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
}),
);
};
setDragOverDropTarget(null);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
"!bg-[#e7ebf0] dark:!bg-white/[0.10]",
isSameDropTarget(confirmedDropTarget, target) &&
"!bg-[#dde3ea] dark:!bg-white/[0.14]",
);
const handleUnmanageGroup = useCallback((groupPath: string) => {
const source = managedSources.find(s => s.groupName === groupPath);
@@ -1479,13 +1554,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Component no longer handles visibility - that's done by VaultViewWrapper
return (
<div ref={rootRef} className="absolute inset-0 min-h-0 flex">
<div ref={rootRef} className="absolute inset-0 min-h-0 flex" data-section="vault-view">
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div className={cn(
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
)}>
<div
className={cn(
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
sidebarCollapsed ? "w-14" : "w-52"
)}
data-section="vault-sidebar"
>
<div className={cn(
"py-4 flex items-center",
sidebarCollapsed ? "px-2 justify-center" : "px-4"
@@ -1648,12 +1726,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</TooltipProvider>
{/* Main Area */}
<div className="flex-1 flex flex-col min-h-0 relative">
<div
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
<header
className={cn(
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
!isHostsSectionActive && "hidden",
)}
data-section="vault-hosts-header"
>
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1 app-no-drag">
@@ -1757,14 +1839,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<CheckSquare size={16} />
</Button>
</div>
{/* New Host split button */}
<div className="flex items-center app-no-drag">
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
would be a no-op in that state. */}
<div
className={cn(
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
: "max-w-[260px] opacity-100",
)}
aria-hidden={isHostPanelOpen}
>
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
onClick={handleNewHost}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
</Button>
@@ -1772,6 +1865,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
</Button>
@@ -1808,22 +1902,37 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</DropdownContent>
</Dropdown>
</div>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={onCreateLocalTerminal}
{/* Terminal + Serial — collapse together with an animation when
the host details / new-host aside panel is open, freeing
horizontal space for the panel. */}
<div
className={cn(
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostPanelOpen
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
: "max-w-[320px] opacity-100",
)}
aria-hidden={isHostPanelOpen}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={onCreateLocalTerminal}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</div>
</header>
@@ -1833,24 +1942,34 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"flex-1 overflow-auto px-4 py-4 space-y-6",
!isHostsSectionActive && "hidden",
)}
data-section="vault-host-list"
onDragEndCapture={() => setDragOverDropTarget(null)}
>
<section className="space-y-2">
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className={cn(
"text-primary hover:underline transition-all rounded px-1 -mx-1",
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
"text-primary hover:underline transition-colors duration-150 rounded px-1 -mx-1",
getDropTargetClasses({ kind: "root" }),
)}
onClick={() => setSelectedGroupPath(null)}
onDragOver={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(true);
setDragOverDropTarget({ kind: "root" });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "root" ? null : current,
);
}}
onDragLeave={() => setIsBreadcrumbDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(false);
setDragOverDropTarget(null);
const groupPath = e.dataTransfer.getData("group-path");
const hostId = e.dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
@@ -1898,9 +2017,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}>
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{pinnedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -1998,9 +2121,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}>
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{recentHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -2099,9 +2226,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
displayedGroups.length === 0 ? "hidden" : "",
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
onDragOver={(e) => {
e.preventDefault();
}}
@@ -2120,10 +2251,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuTrigger asChild>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer transition-colors duration-150",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
getDropTargetClasses({ kind: "group", path: node.path }),
)}
draggable
onDragStart={(e) =>
@@ -2136,10 +2268,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget({ kind: "group", path: node.path });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "group" && current.path === node.path ? null : current,
);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget(null);
const hostId =
e.dataTransfer.getData("host-id");
const groupPath =
@@ -2306,6 +2449,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
@@ -2323,9 +2470,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host);
@@ -2464,9 +2615,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0",
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host);
@@ -2732,6 +2887,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}}
layout="inline"
/>
)}
@@ -2771,6 +2927,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
);
}}
layout="inline"
/>
)}
@@ -2791,6 +2948,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsHostPanelOpen(false);
setEditingHost(null);
}}
layout="inline"
/>
)}

View File

@@ -7,7 +7,7 @@ import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel';
import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -24,6 +24,7 @@ export interface ChainPanelProps {
onClearChain: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const ChainPanel: React.FC<ChainPanelProps> = ({
@@ -37,6 +38,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onClearChain,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
@@ -54,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
title={t('hostDetails.chain.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack}>
{t('common.save')}

View File

@@ -5,7 +5,7 @@
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
title={t('hostDetails.group.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
{t('common.save')}

View File

@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { EnvVar } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
onSave,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
title={t('hostDetails.envVars.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onSave}>
{t('common.save')}

View File

@@ -7,7 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
return (
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
{t('common.save')}

View File

@@ -75,21 +75,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
if (isAlternateScreen) {
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
if (rightClickBehavior === 'paste') {
onPaste?.();
} else if (rightClickBehavior === 'select-word') {
e.preventDefault();
e.stopPropagation();
onSelectWord?.();
}
},
@@ -102,12 +107,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenu>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
onContextMenu={handleRightClick}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
{!isAlternateScreen && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />

View File

@@ -0,0 +1,85 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type InternalTerminal = XTerm & {
_core?: {
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_eraseAttrData?: () => unknown;
};
};
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
return 0;
}
const baseY = buffer.baseY;
for (let row = term.rows - 1; row >= 0; row--) {
const line = buffer.getLine(baseY + row);
if (!line) {
continue;
}
if (line.translateToString(true).length > 0) {
return row + 1;
}
}
return 0;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
return;
}
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) {
return;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
};
export const clearTerminalViewport = (term: XTerm): void => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") return;
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY === 0 && buffer.baseY === 0) return;
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) return;
// Push lines above cursor into scrollback so they are preserved.
// After cursorY scrolls the prompt line shifts to active-screen row 0.
for (let i = 0; i < cursorY; i++) {
scroll.call(internal._core, eraseAttr, false);
}
// Clear everything below the prompt and reposition the cursor on it.
// CSI coordinates are 1-indexed.
const col = cursorX + 1;
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
term.scrollToBottom();
});
};
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 3;
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { clearTerminalViewport } from "../clearTerminalViewport";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -65,7 +66,7 @@ export const useTerminalContextActions = ({
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
term.clear();
clearTerminalViewport(term);
}, [termRef]);
const onSelectWord = useCallback(() => {

View File

@@ -31,6 +31,12 @@ import {
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
isEraseViewportSequence,
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import type {
Host,
KeyBinding,
@@ -129,6 +135,21 @@ const detectPlatform = (): XTermPlatform => {
return "darwin";
};
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
const first = match?.[0]?.trim();
return first || fontFamily;
};
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
const platform = detectPlatform();
const deviceMemoryGb =
@@ -180,7 +201,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
@@ -483,7 +504,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
break;
}
case "clearBuffer": {
term.clear();
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
@@ -626,6 +647,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
if (isEraseViewportSequence(params)) {
preserveTerminalViewportInScrollback(term);
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
return true;
});
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
@@ -748,6 +780,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
@@ -44,6 +44,12 @@ interface AsidePanelProps {
children: ReactNode;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
*/
dataSection?: string;
}
interface AsidePanelHeaderProps {
@@ -171,14 +177,40 @@ interface AsidePanelStackProps {
initialItem: AsideContentItem;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS.
*/
dataSection?: string;
}
export type AsidePanelLayout = 'overlay' | 'inline';
const resolveInlineWidth = (width: string) => {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
return arbitraryWidthMatch[1];
}
switch (width) {
case 'w-full':
return '100%';
case 'w-screen':
return '100vw';
default:
return '380px';
}
};
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
open,
onClose,
initialItem,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
@@ -205,6 +237,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
const currentItem = stack[stack.length - 1];
const canGoBack = stack.length > 1;
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
// Reset stack when panel closes/opens
React.useEffect(() => {
@@ -218,10 +257,14 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
<AsidePanelHeader
title={currentItem.title}
subtitle={currentItem.subtitle}
@@ -248,15 +291,29 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
children,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
if (!open) return null;
const inlineWidth = resolveInlineWidth(width);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}>
)}
style={inlineStyle}
data-section={dataSection}>
{title && (
<AsidePanelHeader
title={title}

View File

@@ -155,6 +155,7 @@ const createHost = (input: {
label?: string;
hostname: string;
username?: string;
password?: string;
port?: number;
protocol?: Exclude<HostProtocol, "mosh">;
group?: string;
@@ -167,6 +168,7 @@ const createHost = (input: {
hostname: input.hostname.trim(),
port: input.port ?? DEFAULT_SSH_PORT,
username: input.username?.trim() ?? "",
password: input.password || undefined,
group: normalizeGroupPath(input.group),
tags: (input.tags ?? []).filter(Boolean),
os: "linux",
@@ -189,6 +191,7 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
duplicates++;
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
existing.tags = mergedTags;
if (!existing.password && host.password) existing.password = host.password;
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
@@ -333,6 +336,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
const passwordIdx = findHeaderIndex(header, ["password", "pass", "passwd"]);
if (hostnameIdx === -1) {
return {
@@ -378,12 +382,14 @@ const importFromCsv = (text: string): VaultImportResult => {
"ssh";
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
parsedHosts.push(
createHost({
label,
hostname: target.hostname,
username,
password,
port,
protocol,
group,
@@ -993,12 +999,12 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root"]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu"]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin"]);
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
}
const escapeCsv = (value: string) => {
@@ -1011,13 +1017,14 @@ export const getVaultCsvTemplate = (
};
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
const escapeCsv = (value: string, skipFormulaGuard = false) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
// Skip for password fields to preserve credentials verbatim for round-trip
if (!skipFormulaGuard && /^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
@@ -1059,10 +1066,12 @@ const exportHostsToCsv = (hosts: Host[]): string => {
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
host.password ?? "",
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
const passwordColIdx = header.indexOf("Password");
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(c, rowIdx > 0 && i === passwordColIdx)).join(",")).join("\r\n") + "\r\n";
};
interface ExportHostsResult {

View File

@@ -82,13 +82,13 @@ function resolveCodexAcpBinaryPath(shellEnv, electronModule) {
// Packaged build (or dev fallback): use npm-bundled binary
try {
const pkgName = getCodexPackageName();
if (!pkgName) return binaryName;
if (!pkgName) return null;
const pkgRoot = path.dirname(require.resolve("@zed-industries/codex-acp/package.json"));
const resolved = require.resolve(`${pkgName}/bin/${binaryName}`, { paths: [pkgRoot] });
return toUnpackedAsarPath(resolved);
} catch {
return binaryName;
return null;
}
}

View File

@@ -145,6 +145,668 @@ function findEndMarker(outputText, marker) {
return null;
}
function normalizePtyOutput(stdout, {
stripMarkers = false,
expectedPrompt = "",
trimOutput = true,
stripPrompt = true,
markerToStrip = null,
} = {}) {
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
// Prefer the job-specific marker so user output that contains "__NCMCP_"
// (e.g. printf '__NCMCP_demo\n') is preserved.
const pattern = markerToStrip
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
cleaned = cleaned.replace(pattern, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
return trimOutput ? cleaned.trim() : cleaned;
}
function appendBoundedOutput(current, chunk, maxBufferedChars) {
const combined = `${current || ""}${chunk || ""}`;
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
if (limit <= 0 || combined.length <= limit) {
return { text: combined, dropped: 0 };
}
const dropped = combined.length - limit;
return {
text: combined.slice(dropped),
dropped,
};
}
function consumeVisibleText(carry, chunk) {
const input = `${carry || ""}${chunk || ""}`;
if (!input) {
return { visibleText: "", carry: "" };
}
let visibleText = "";
let index = 0;
while (index < input.length) {
const ch = input[index];
if (ch === "\r") {
// Preserve \r so consumers / serializers can collapse progress-bar
// redraws to the latest frame. \r\n becomes a single \n.
if (input[index + 1] === "\n") {
visibleText += "\n";
index += 2;
continue;
}
visibleText += "\r";
index += 1;
continue;
}
if (ch !== "\u001b") {
visibleText += ch;
index += 1;
continue;
}
if (index + 1 >= input.length) {
break;
}
const next = input[index + 1];
if (next === "[") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const code = input.charCodeAt(cursor);
if (code >= 0x40 && code <= 0x7e) {
index = cursor + 1;
complete = true;
break;
}
cursor += 1;
}
if (!complete) break;
continue;
}
if (next === "]") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const oscChar = input[cursor];
if (oscChar === "\u0007") {
index = cursor + 1;
complete = true;
break;
}
if (oscChar === "\u001b") {
if (cursor + 1 >= input.length) break;
if (input[cursor + 1] === "\\") {
index = cursor + 2;
complete = true;
break;
}
}
cursor += 1;
}
if (!complete) break;
continue;
}
visibleText += ch;
index += 1;
}
return {
visibleText,
carry: input.slice(index),
};
}
function startPtyJob(ptyStream, command, options) {
const {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
typedInput = false,
echoCommand,
maxBufferedChars = 0,
normalizeFinalOutput = true,
enforceWallTimeout = false,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
const CANCEL_RETRY_MS = 5000;
const CANCEL_WALL_TIMEOUT_MS = 30000;
let output = "";
let foundStart = false;
let preStartOutput = "";
let visibleOutput = "";
let visibleOutputOffset = 0;
// Monotonic high-water mark for the visible byte stream. Increases on every
// append; never decreases when CR redraws collapse visibleOutput. Used as
// the polling nextOffset so callers' offsets stay monotonic.
let visibleHighWatermark = 0;
let visibleCarry = "";
let timeoutId = null;
let wallTimeoutId = null;
let startupTimeoutId = null;
let promptFallbackTimer = null;
let cancelRetryTimerId = null;
// Track one-shot timers scheduled inside requestCancel so finish() can
// clear them when the job exits early; otherwise they keep the Node
// event loop alive after the resultPromise has already resolved.
const cancelOneShotTimers = [];
let cancelRequested = false;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
let pendingStart = "";
let resolveResult;
const resultPromise = new Promise((resolve) => {
resolveResult = resolve;
});
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function clearCancelRetryTimer() {
if (cancelRetryTimerId) {
clearTimeout(cancelRetryTimerId);
cancelRetryTimerId = null;
}
}
function armOutputTimeout() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
sendInterrupt();
if (cancelRequested) {
armOutputTimeout();
return;
}
const timeoutSec = Math.round(timeoutMs / 1000);
finish(foundStart ? output : preStartOutput, -1, `Command timed out after ${timeoutSec}s without output`);
}, timeoutMs);
}
// Hard wall-clock deadline: opt-in via enforceWallTimeout. Used by callers
// that have a strict tool-call budget (e.g. MCP terminal_execute, where the
// model can fall back to terminal_start). Default is off so existing
// foreground execution paths (Catty Agent) keep their inactivity-based
// timeout for long-running streaming commands.
function armWallTimeout() {
if (!enforceWallTimeout || maxBufferedChars > 0) return;
wallTimeoutId = setTimeout(() => {
if (finished) return;
sendInterrupt();
const timeoutSec = Math.round(timeoutMs / 1000);
finish(foundStart ? output : preStartOutput, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
}
// Bounded startup deadline: we always need a hard limit on how long we
// wait for the wrapped command's start marker. Otherwise an already-chatty
// PTY (e.g. a tab running tail -f) would let onData re-arm the inactivity
// timer forever before _S arrives, hanging the call and the session lock.
// Foreground execs use the configured timeoutMs as the deadline (matching
// the pre-PR behavior); background jobs use a fixed 30s since their main
// timeout is much longer (1 hour) and meant for the actual command.
const BG_STARTUP_TIMEOUT_MS = 30000;
function armStartupTimeout() {
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
startupTimeoutId = setTimeout(() => {
if (finished || foundStart) return;
sendInterrupt();
const label = maxBufferedChars > 0 ? "Background job startup" : "Command startup";
finish(preStartOutput, -1, `${label} timed out — start marker never arrived`);
}, startupMs);
}
function clearStartupTimeout() {
if (startupTimeoutId) {
clearTimeout(startupTimeoutId);
startupTimeoutId = null;
}
}
function sendInterrupt() {
try {
if (typeof ptyStream.signal === "function") {
ptyStream.signal("INT");
}
} catch {
// Ignore signal failures and fall back to ETX.
}
try {
if (typeof ptyStream.write === "function") {
ptyStream.write("\x03");
}
} catch {
// Ignore PTY write failures during cancellation.
}
}
function requestCancel() {
if (finished || cancelRequested) return;
cancelRequested = true;
clearPromptFallback();
clearCancelRetryTimer();
// Cancel the startup timer too — otherwise a pre-start cancel resolves
// as "Background job startup timed out" instead of "Cancelled".
clearStartupTimeout();
// For pre-start cancellation on sessions without a known idle prompt,
// schedule a short fallback to finish the job after Ctrl+C has had time
// to take effect. Without this, the cancel waits the full forced-cancel
// window even though the shell may have returned to idle quickly.
if (!foundStart && !expectedPrompt) {
const t = setTimeout(() => {
if (finished || foundStart) return;
finish(preStartOutput, 130, "Cancelled");
}, 2000);
cancelOneShotTimers.push(t);
}
sendInterrupt();
cancelRetryTimerId = setTimeout(function retryCancel() {
if (finished || !cancelRequested) return;
sendInterrupt();
cancelRetryTimerId = setTimeout(retryCancel, CANCEL_RETRY_MS);
}, CANCEL_RETRY_MS);
armOutputTimeout();
const t150 = setTimeout(() => {
if (!finished) sendInterrupt();
}, 150);
cancelOneShotTimers.push(t150);
// Hard wall-clock deadline for cancellation: if the process ignores
// Ctrl+C and never redraws the prompt, force-finish after a bounded
// period so the session is not stuck in "stopping" forever.
// Mark as "forced" so callers can tell the shell may still be busy.
const tWall = setTimeout(() => {
if (!finished) {
finish(foundStart ? output : preStartOutput, 130, "Cancelled (forced — process may still be running)");
}
}, CANCEL_WALL_TIMEOUT_MS);
cancelOneShotTimers.push(tWall);
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Background jobs use a much longer delay (30s) so commands that open
// child shells / REPLs with the same prompt have time to print past
// their initial prompt and avoid being misdetected as completed.
// Foreground execs use 250ms to match the pre-PR behavior.
const delayMs = maxBufferedChars > 0 ? 30000 : 250;
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, delayMs);
}
function checkEnd() {
const found = findEndMarker(output, marker);
if (!found) return;
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
}
// Carry buffer for incomplete marker lines split across chunks.
let visibleMarkerCarry = "";
// Note: we intentionally do NOT collapse CR redraws in visibleOutput.
// Doing so makes polling offsets non-monotonic and can drop finalized
// lines after a CR rewrite. Instead, the buffer stores raw bytes
// (including \r) and the bounded-buffer cap (256KB) keeps progress-bar
// accumulation under control. Consumers that want a "collapsed" view
// can apply CR processing themselves.
function appendToVisible(text) {
if (!text) return;
const normalized = consumeVisibleText(visibleCarry, text);
visibleCarry = normalized.carry;
if (!normalized.visibleText) return;
let cleanVisible = normalized.visibleText;
if (maxBufferedChars > 0) {
// Rejoin with any incomplete line from the previous chunk so marker
// lines split across PTY data boundaries are matched as a whole.
cleanVisible = visibleMarkerCarry + cleanVisible;
visibleMarkerCarry = "";
// We must withhold any trailing line that *might* be the start of an
// internal marker line, even if the random marker token isn't fully
// present yet (the chunk boundary may split the marker mid-token).
// Detect this by looking for the constant prefix "__NCMCP_" — only
// user output that *contains an unrelated __NCMCP_ string and ends
// with a newline* will be preserved through the next strip step.
const NCMCP_PREFIX = "__NCMCP_";
const lastNl = cleanVisible.lastIndexOf("\n");
if (lastNl === -1) {
if (cleanVisible.includes(NCMCP_PREFIX)) {
visibleMarkerCarry = cleanVisible;
return;
}
} else if (lastNl < cleanVisible.length - 1) {
const trailing = cleanVisible.slice(lastNl + 1);
if (trailing.includes(NCMCP_PREFIX)) {
visibleMarkerCarry = trailing;
cleanVisible = cleanVisible.slice(0, lastNl + 1);
}
}
// Strip only this job's specific marker lines so user output that
// happens to contain "__NCMCP_" (e.g. printf '__NCMCP_demo\n') is
// preserved.
cleanVisible = cleanVisible.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
if (!cleanVisible) return;
}
visibleHighWatermark += cleanVisible.length;
const next = appendBoundedOutput(visibleOutput, cleanVisible, maxBufferedChars);
visibleOutput = next.text;
visibleOutputOffset += next.dropped;
}
function appendToOutput(text) {
if (!text) return;
const next = appendBoundedOutput(output, text, maxBufferedChars);
output = next.text;
appendToVisible(text);
}
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearTimeout(wallTimeoutId);
clearStartupTimeout();
clearPromptFallback();
clearCancelRetryTimer();
// Clear any pending one-shot cancel timers so they do not keep the
// Node event loop alive after the job has resolved.
while (cancelOneShotTimers.length) {
clearTimeout(cancelOneShotTimers.pop());
}
unsubscribe?.();
for (const fn of cleanupFns) {
try {
fn();
} catch {
// Ignore cleanup failures
}
}
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
// Flush any incomplete marker carry — if it wasn't this job's marker, append it.
if (visibleMarkerCarry) {
const leftover = visibleMarkerCarry.replace(new RegExp(`^[^\r\n]*${marker}[^\r\n]*[\r\n]*`, "gm"), "");
visibleMarkerCarry = "";
if (leftover) {
const next = appendBoundedOutput(visibleOutput, leftover, maxBufferedChars);
visibleOutput = next.text;
visibleOutputOffset += next.dropped;
}
}
// For background jobs (maxBufferedChars > 0), use the already-stripped
// visibleOutput so completion offsets are consistent with polling offsets.
// Re-normalizing from the raw buffer would produce a shorter result because
// ANSI codes inflate the raw buffer, causing it to truncate earlier.
let cleaned;
let outputBaseOffset;
let totalOutputChars;
if (maxBufferedChars > 0 && foundStart) {
// Always strip this job's markers from the visible buffer — it accumulates
// raw PTY data including the end-marker line that must not leak to callers.
const strippedVisible = normalizePtyOutput(visibleOutput, {
stripMarkers: true,
markerToStrip: marker,
expectedPrompt,
trimOutput: normalizeFinalOutput,
stripPrompt: true,
});
cleaned = strippedVisible;
outputBaseOffset = visibleOutputOffset;
totalOutputChars = outputBaseOffset + visibleOutput.length;
} else {
const visibleStdout = normalizePtyOutput(stdout, {
stripMarkers,
markerToStrip: marker,
expectedPrompt,
trimOutput: false,
stripPrompt: true,
});
cleaned = normalizeFinalOutput
? normalizePtyOutput(stdout, {
stripMarkers,
markerToStrip: marker,
expectedPrompt,
trimOutput: true,
stripPrompt: true,
})
: visibleStdout;
outputBaseOffset = foundStart ? visibleOutputOffset : 0;
totalOutputChars = outputBaseOffset + visibleStdout.length;
}
const finalError = (!error && cancelRequested) ? "Cancelled" : error;
const finalExitCode = finalError === "Cancelled" ? (exitCode ?? 130) : exitCode;
if (finalError) {
resolveResult({
ok: false,
stdout: cleaned,
stderr: "",
exitCode: finalExitCode ?? -1,
error: finalError,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
});
} else {
resolveResult({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: finalExitCode ?? 0,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
});
}
}
function onData(data) {
const text = data.toString();
armOutputTimeout();
if (!foundStart) {
preStartOutput += text;
// Cap preStartOutput for background jobs so a noisy idle PTY can't
// accumulate megabytes before the start marker arrives. We only need
// enough tail to find the marker boundary.
if (maxBufferedChars > 0 && preStartOutput.length > maxBufferedChars) {
preStartOutput = preStartOutput.slice(preStartOutput.length - maxBufferedChars);
}
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
const lines = combined.split(/\r?\n/);
const trailingPartial = /[\r\n]$/.test(combined) ? "" : lines.pop() || "";
for (const line of lines) {
if (stripAnsi(line).trim() === startMarker) {
foundStart = true;
matched = true;
break;
}
}
pendingStart = trailingPartial;
if (foundStart) {
clearStartupTimeout();
// Use the *last* occurrence of the start marker to skip the echoed
// wrapper command and capture only output after the real printf line.
const markerPattern = new RegExp(`${marker}_S[^\n\r]*(?:\r?\n|$)`, "g");
let boundary = -1;
let m;
while ((m = markerPattern.exec(preStartOutput)) !== null) {
boundary = m.index;
}
if (boundary !== -1) {
const afterBoundary = preStartOutput.slice(boundary);
const firstNl = afterBoundary.search(/\r?\n/);
const initialOutput = firstNl === -1 ? "" : afterBoundary.slice(firstNl).replace(/^\r?\n/, "");
output = "";
visibleOutput = "";
visibleOutputOffset = 0;
visibleCarry = "";
appendToOutput(initialOutput);
}
preStartOutput = "";
schedulePromptFallback();
checkEnd();
return;
}
if (!matched) {
const fallbackEnd = findEndMarker(preStartOutput, marker);
if (fallbackEnd) {
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
const lastStartIdx = stdout.lastIndexOf(startMarker);
if (lastStartIdx !== -1) {
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
if (nlAfterStart !== -1) {
stdout = stdout.slice(nlAfterStart + 1);
}
}
finish(stdout, fallbackEnd.exitCode);
return;
}
}
// If we're cancelling a still-queued command and the shell has returned
// to its idle prompt, finish immediately as Cancelled instead of waiting
// for the cancel wall-clock timer.
if (cancelRequested && hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) {
finish(preStartOutput, 130, "Cancelled");
return;
}
return;
}
appendToOutput(text);
if (!cancelRequested) {
schedulePromptFallback();
} else if (hasExpectedPromptSuffix(output, expectedPrompt)) {
finish(output, 130, "Cancelled");
return;
}
checkEnd();
}
if (abortSignal?.aborted) {
finish("", -1, "Cancelled");
return {
marker,
cancel: () => {},
getSnapshot: () => ({ stdout: "", status: "cancelled", foundStart: false }),
resultPromise,
};
}
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
unsubscribe = subscribeToPtyData(ptyStream, onData);
const cancel = () => {
requestCancel();
};
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel,
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
},
});
}
if (typeof ptyStream.on === "function") {
const onClose = () => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly");
const onError = (err) => finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch {}
try { ptyStream.removeListener("end", onClose); } catch {}
try { ptyStream.removeListener("error", onError); } catch {}
});
}
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"));
cleanupFns.push(() => {
try {
disposable?.dispose?.();
} catch {
// Ignore cleanup failures
}
});
}
if (abortSignal) {
const onAbort = () => {
requestCancel();
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
return {
marker,
cancel,
// Until the start marker arrives, return empty stdout/zero offsets so
// an early poll cannot advance nextOffset past pre-start PTY noise that
// gets discarded once the real command begins.
getSnapshot: () => ({
stdout: foundStart ? visibleOutput : "",
outputBaseOffset: foundStart ? visibleOutputOffset : 0,
totalOutputChars: foundStart ? visibleOutputOffset + visibleOutput.length : 0,
outputTruncated: foundStart ? visibleOutputOffset > 0 : false,
status: finished ? "finished" : (cancelRequested ? "stopping" : "running"),
foundStart,
}),
resultPromise,
};
}
/**
* Execute command through a terminal PTY stream.
* The user sees the command typed and output in their terminal.
@@ -163,228 +825,7 @@ function findEndMarker(outputText, marker) {
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
*/
function execViaPty(ptyStream, command, options) {
const {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
chatSessionId,
abortSignal,
expectedPrompt,
typedInput = false,
echoCommand,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
// Fast-path: already aborted before we even start
if (abortSignal?.aborted) {
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
}
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let preStartOutput = "";
let timeoutId = null;
let promptFallbackTimer = null;
let finished = false;
let unsubscribe = null;
const cleanupFns = [];
// Buffer for incomplete line data when searching for start marker.
// SSH channels can split data at arbitrary byte boundaries, so the
// start marker may arrive across two chunks. We keep the content
// after the last \n (i.e. the current incomplete line) and prepend
// it to the next chunk so indexOf can match the full marker.
let pendingStart = "";
const onData = (data) => {
const text = data.toString();
if (!foundStart) {
preStartOutput += text;
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
let matched = false;
let pos = 0;
while (pos < combined.length) {
const idx = combined.indexOf(startMarker, pos);
if (idx === -1) break;
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
foundStart = true;
matched = true;
const afterMarker = combined.slice(idx);
const nlIdx = afterMarker.indexOf("\n");
if (nlIdx !== -1) {
output += afterMarker.slice(nlIdx + 1);
}
break;
}
pos = idx + 1;
}
if (!matched) {
// Keep the last incomplete line for cross-chunk matching
const lastNl = combined.lastIndexOf("\n");
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
}
if (foundStart) {
preStartOutput = "";
schedulePromptFallback();
checkEnd();
return;
}
// Fallback: if strict start-marker detection missed (e.g. due shell
// control sequence prefixes), still complete as soon as we observe a
// valid end marker with exit code.
const fallbackEnd = findEndMarker(preStartOutput, marker);
if (fallbackEnd) {
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
const lastStartIdx = stdout.lastIndexOf(startMarker);
if (lastStartIdx !== -1) {
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
if (nlAfterStart !== -1) {
stdout = stdout.slice(nlAfterStart + 1);
}
}
finish(stdout, fallbackEnd.exitCode);
}
return;
}
output += text;
schedulePromptFallback();
checkEnd();
};
function clearPromptFallback() {
if (promptFallbackTimer) {
clearTimeout(promptFallbackTimer);
promptFallbackTimer = null;
}
}
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
// Fallback for shells that visibly return to the same idle prompt but
// never emit the wrapped end marker line.
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
finish(output, null, null);
}, 250);
}
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const found = findEndMarker(output, marker);
if (!found) return;
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
}
function finish(stdout, exitCode, error) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
clearPromptFallback();
unsubscribe?.();
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
cleaned = cleaned.trim();
if (error) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
} else {
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
}
timeoutId = setTimeout(() => {
// Send Ctrl+C to kill the timed-out command
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
const timeoutSec = Math.round(timeoutMs / 1000);
finish(output, -1, `Command timed out (${timeoutSec}s)`);
}, timeoutMs);
unsubscribe = subscribeToPtyData(ptyStream, onData);
// Register for cancellation if tracking map provided
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
chatSessionId: chatSessionId || null,
cancel: () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
},
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
},
});
}
// Stream close/error detection — resolve immediately instead of waiting for timeout
if (typeof ptyStream.on === "function") {
const onClose = () => finish(output, null, "Stream closed unexpectedly");
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
cleanupFns.push(() => {
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
try { ptyStream.removeListener("error", onError); } catch { /* */ }
});
}
// node-pty uses onExit instead of close/end
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
}
// AbortSignal handling — send Ctrl+C and resolve when aborted
if (abortSignal) {
const onAbort = () => {
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
finish(output, -1, "Cancelled");
};
abortSignal.addEventListener("abort", onAbort, { once: true });
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
// Markers are filtered from terminal display by preload.cjs.
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
return startPtyJob(ptyStream, command, options).resultPromise;
}
/**
@@ -659,6 +1100,7 @@ execViaRawPty._seq = 0;
module.exports = {
execViaPty,
startPtyJob,
execViaChannel,
execViaRawPty,
detectShellKind,

View File

@@ -1029,6 +1029,19 @@ function registerHandlers(ipcMain) {
return { ok: false, error: "Session not found" };
}
// Honor the per-session execution lock so this IPC path does not race with
// long-running background jobs started via terminal_start.
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
if (busyErr) return busyErr;
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
if (reservation && !reservation.ok) return reservation;
const sessionToken = reservation?.token;
const releaseLock = () => {
if (sessionToken) {
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
}
};
// 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)
@@ -1043,12 +1056,26 @@ function registerHandlers(ipcMain) {
if (!isNetworkDevice) {
const safety = mcpServerBridge.checkCommandSafety(command);
if (safety.blocked) {
releaseLock();
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
// Helper: ensure the session lock is released once the promise settles
// (or immediately on a synchronous error/early return).
const withLockRelease = (factory) => {
try {
const result = factory();
return Promise.resolve(result).finally(releaseLock);
} catch (err) {
releaseLock();
return { ok: false, error: err?.message || String(err) };
}
};
try {
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
releaseLock();
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
@@ -1062,18 +1089,18 @@ function registerHandlers(ipcMain) {
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaRawPty(ptyStream, command, {
return withLockRelease(() => 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, {
return withLockRelease(() => execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
timeoutMs,
@@ -1089,11 +1116,16 @@ function registerHandlers(ipcMain) {
syntheticEcho: true,
});
},
});
// Catty Agent has no terminal_start fallback for long-running
// commands, so do NOT enforce a hard wall-clock timeout here.
// The inactivity timeout still applies, so genuinely hung
// processes are still terminated.
}));
}
// Network devices require an interactive PTY for raw command execution.
if (isNetworkDevice) {
releaseLock();
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
@@ -1102,27 +1134,29 @@ function registerHandlers(ipcMain) {
if (sshClient && typeof sshClient.exec === "function") {
const { execViaChannel } = require("./ai/ptyExec.cjs");
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaChannel(sshClient, command, {
return withLockRelease(() => execViaChannel(sshClient, command, {
timeoutMs: channelTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
}));
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaRawPty(session.serialPort, command, {
return withLockRelease(() => execViaRawPty(session.serialPort, command, {
timeoutMs: serialTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: session.serialEncoding || "utf8",
});
}));
}
releaseLock();
return { ok: false, error: "No terminal stream or SSH client available for this session" };
} catch (err) {
releaseLock();
return { ok: false, error: err?.message || String(err) };
}
});
@@ -1208,8 +1242,14 @@ function registerHandlers(ipcMain) {
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const shellEnv = await getShellEnv();
const resolvedCommand = resolveCodexAcpBinaryPath(shellEnv, electronModule);
if (!resolvedCommand) {
const result = { ok: false, checkedAt: now, error: "codex-acp binary not found", code: "ENOENT" };
setCodexValidationCache(result);
return result;
}
const provider = createACPProvider({
command: resolveCodexAcpBinaryPath(shellEnv, electronModule),
command: resolvedCommand,
env: shellEnv,
session: {
cwd: process.cwd(),
@@ -1927,6 +1967,9 @@ function registerHandlers(ipcMain) {
: claudeAcp
? claudeAcp.command
: acpCommand;
if (!resolvedCommand) {
return { ok: false, models: [], error: `${agentLabel} binary not found` };
}
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
@@ -2117,6 +2160,9 @@ function registerHandlers(ipcMain) {
: claudeAcp
? claudeAcp.command
: acpCommand;
if (!resolvedCommand) {
throw new Error(`${agentLabel} binary not found`);
}
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
@@ -2185,12 +2231,16 @@ function registerHandlers(ipcMain) {
cleanupAcpProvider(chatSessionId);
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const fallbackCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: fallbackClaudeAcp
? fallbackClaudeAcp.command
: acpCommand;
if (!fallbackCommand) {
throw new Error(`${agentLabel} binary not found`);
}
const fallbackProvider = createACPProvider({
command: isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: fallbackClaudeAcp
? fallbackClaudeAcp.command
: acpCommand,
command: fallbackCommand,
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
@@ -2250,7 +2300,9 @@ function registerHandlers(ipcMain) {
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
`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. ` +
`Use terminal_execute only for commands likely to finish within about 60 seconds. ` +
`For long-running commands such as builds, scans, follow/log streaming, watch commands, or anything likely to exceed 60 seconds on PTY-backed shell sessions, use terminal_start, then terminal_poll until completed is true. Reuse the returned nextOffset for the next poll. If terminal_poll reports outputTruncated=true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls, and increase the interval further when there is no new output, to avoid wasting tokens. As soon as completed is true, stop polling and analyze the result immediately. Note: terminal_start requires a PTY-backed session; for sessions that only support exec-channel execution (no writable PTY), use terminal_execute instead. ` +
`Use terminal_stop if you need to interrupt a started long-running command. ` +
`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
@@ -2427,7 +2479,11 @@ function registerHandlers(ipcMain) {
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
const effectiveRequestId = requestId || activeRun?.requestId || "";
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
// Cancel synchronous PTY executions scoped to this chat session (send Ctrl+C).
// Do NOT cancel terminal_start background jobs here — they were intentionally
// launched as long-running and should keep running when the user only wants
// to stop the model's polling/output. Background jobs are still cleaned up
// when the chat session itself is deleted (see cleanupScopedMetadata).
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);

View File

@@ -12,7 +12,7 @@ const path = require("node:path");
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { execViaPty, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
const { safeSend } = require("./ipcUtils.cjs");
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
@@ -48,6 +48,13 @@ let permissionMode = "confirm";
// Track active PTY executions for cancellation
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
const cancelledChatSessions = new Set();
const backgroundJobs = new Map(); // jobId -> job metadata
const activeSessionExecutions = new Map(); // sessionId -> { kind, startedAt, token }
const pendingSessionWriteApprovals = new Map(); // sessionId -> method
const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
const BACKGROUND_JOB_RETENTION_MS = 10 * 60 * 1000;
const MAX_BACKGROUND_JOB_OUTPUT_CHARS = 256 * 1024;
// ── Approval gate (for confirm mode with ACP/MCP agents) ──
let getMainWindowFn = null; // () => BrowserWindow | null
@@ -161,6 +168,207 @@ function cancelPtyExecsForSession(chatSessionId) {
}
}
function createBackgroundJobId() {
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
}
function cancelBackgroundJobsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const [, job] of backgroundJobs) {
if (job.chatSessionId !== chatSessionId) continue;
if (job.status !== "running") continue;
try {
job.handle?.cancel?.();
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
} catch {
// Ignore cancellation failures
}
}
}
function readBackgroundJobSnapshot(job) {
if (!job) {
return {
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
};
}
if (job.status === "running" || job.status === "stopping") {
const snapshot = job.handle?.getSnapshot?.();
if (snapshot) {
const stdout = String(snapshot.stdout || "");
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
return {
stdout,
outputBaseOffset,
totalOutputChars,
outputTruncated: Boolean(snapshot.outputTruncated),
};
}
}
const stdout = String(job.stdout || "");
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
return {
stdout,
outputBaseOffset,
totalOutputChars,
outputTruncated: Boolean(job.outputTruncated),
};
}
function createOutputWindow(stdout) {
const fullText = String(stdout || "");
const totalOutputChars = fullText.length;
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
return {
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
};
}
function refreshRunningJobSnapshot(job) {
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
const snapshot = readBackgroundJobSnapshot(job);
job.stdout = snapshot.stdout;
job.outputBaseOffset = snapshot.outputBaseOffset;
job.totalOutputChars = snapshot.totalOutputChars;
job.outputTruncated = snapshot.outputTruncated;
}
function storeCompletedJobOutput(job, stdout, metadata = null) {
if (metadata && typeof metadata === "object") {
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
job.stdout = normalizedStdout;
job.outputBaseOffset = outputBaseOffset;
job.totalOutputChars = totalOutputChars;
job.outputTruncated = Boolean(metadata.outputTruncated);
job.handle = null;
return;
}
const window = createOutputWindow(stdout);
job.stdout = window.stdout;
job.outputBaseOffset = window.outputBaseOffset;
job.totalOutputChars = window.totalOutputChars;
job.outputTruncated = window.outputTruncated;
job.handle = null;
}
function pruneCompletedBackgroundJobs(now = Date.now()) {
for (const [jobId, job] of backgroundJobs) {
if (job.status === "running" || job.status === "stopping") continue;
const updatedAt = Number(job.updatedAt) || 0;
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
backgroundJobs.delete(jobId);
}
}
}
// Collapse carriage-return progress redraws to the latest frame.
// Each \r resets the cursor to the start of the current line; the next
// non-\r character overwrites the existing line content. A trailing \r
// (with no following content) leaves the existing line intact, so a
// snapshot taken between redraws still shows the latest visible frame.
// Used at serialize time so the stored buffer can keep raw monotonic
// offsets while polled output shows the latest frame.
function collapseCarriageReturns(text) {
if (!text || text.indexOf("\r") === -1) return text;
let result = "";
let crPending = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\r") {
crPending = true;
continue;
}
if (ch === "\n") {
crPending = false;
result += ch;
continue;
}
if (crPending) {
const lastNl = result.lastIndexOf("\n");
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
crPending = false;
}
result += ch;
}
return result;
}
function serializeBackgroundJob(job, offset = 0) {
if (job.status === "running" || job.status === "stopping") {
refreshRunningJobSnapshot(job);
}
const stdout = job.stdout || "";
const outputBaseOffset = job.outputBaseOffset || 0;
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
const numericOffset = Math.max(0, Number(offset) || 0);
const relativeOffset = numericOffset <= outputBaseOffset
? 0
: Math.min(numericOffset - outputBaseOffset, stdout.length);
return {
ok: true,
jobId: job.id,
sessionId: job.sessionId,
command: job.command,
status: job.status,
completed: job.status !== "running" && job.status !== "stopping",
exitCode: job.exitCode,
error: job.error,
startedAt: job.startedAt,
updatedAt: job.updatedAt,
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
nextOffset: totalOutputChars,
totalOutputChars,
outputBaseOffset,
outputTruncated: Boolean(job.outputTruncated),
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
};
}
function describeActiveSessionExecution(entry) {
if (!entry) return "another command";
return entry.kind === "job" ? "a long-running command" : "another command";
}
function getSessionBusyError(sessionId) {
const active = activeSessionExecutions.get(sessionId);
if (!active) return null;
return {
ok: false,
error: `Session already has ${describeActiveSessionExecution(active)} in progress. Wait for it to finish or stop it before starting another command.`,
};
}
function reserveSessionExecution(sessionId, kind) {
const existing = getSessionBusyError(sessionId);
if (existing) return existing;
const token = `${kind}_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
activeSessionExecutions.set(sessionId, {
kind,
startedAt: Date.now(),
token,
});
return { ok: true, token };
}
function releaseSessionExecution(sessionId, token) {
const active = activeSessionExecutions.get(sessionId);
if (!active) return;
if (token && active.token !== token) return;
activeSessionExecutions.delete(sessionId);
}
function init(deps) {
sessions = deps.sessions;
electronModule = deps.electronModule || null;
@@ -413,14 +621,35 @@ async function handleMessage(socket, line) {
// Methods that modify remote state — blocked in observer mode
const WRITE_METHODS = new Set([
"netcatty/exec",
"netcatty/jobStart",
"netcatty/jobStop",
]);
/**
* Validate that a sessionId is allowed in the current scope.
* Checks both process-level SCOPED_SESSION_IDS and per-chatSession scoped metadata.
* Checks explicit per-call scopedSessionIds first (static MCP scope mode),
* then per-chatSession scoped metadata (dynamic mode), then global scope.
*
* An explicit empty array (`[]`) means "no access" — not "fall through to
* global scope" — matching the documented behavior in handleGetContext.
*/
function validateSessionScope(sessionId, chatSessionId) {
function validateSessionScope(sessionId, chatSessionId, explicitScopedIds = null) {
if (!sessionId) return null; // will fail at handler level
if (Array.isArray(explicitScopedIds)) {
if (!explicitScopedIds.includes(sessionId)) {
return `Session "${sessionId}" is not in the current scope.`;
}
return null;
}
// If a chat has explicit scoped metadata (even an empty array), enforce it.
// Only fall through to fallback/global when no chat-scoped context exists.
if (chatSessionId && scopedMetadata.has(chatSessionId)) {
const chatScoped = scopedMetadata.get(chatSessionId)?.sessionIds || [];
if (!chatScoped.includes(sessionId)) {
return `Session "${sessionId}" is not in the current scope.`;
}
return null;
}
const scopedIds = getScopedSessionIds(chatSessionId);
if (scopedIds && scopedIds.length > 0 && !scopedIds.includes(sessionId)) {
return `Session "${sessionId}" is not in the current scope.`;
@@ -429,36 +658,78 @@ function validateSessionScope(sessionId, chatSessionId) {
}
async function dispatch(method, params) {
// Observer mode: block all write operations
if (permissionMode === "observer" && WRITE_METHODS.has(method)) {
const sessionWriteLockId = (method === "netcatty/exec" || method === "netcatty/jobStart") ? params?.sessionId : null;
pruneCompletedBackgroundJobs();
// Observer mode: block all write operations *except* netcatty/jobStop,
// which must remain available so users can interrupt long-running jobs
// they started before switching to observer mode (otherwise the job
// would hold the per-session lock until it exits on its own).
if (permissionMode === "observer" && WRITE_METHODS.has(method) && method !== "netcatty/jobStop") {
return { ok: false, error: `Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.` };
}
if (WRITE_METHODS.has(method) && isChatSessionCancelled(params?.chatSessionId)) {
// netcatty/jobStop must remain callable after ACP cancel so users can stop
// a long-running terminal_start job (which intentionally survives ACP Stop)
// even from a chat session whose write methods are otherwise blocked.
if (WRITE_METHODS.has(method) && method !== "netcatty/jobStop" && isChatSessionCancelled(params?.chatSessionId)) {
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
}
// Confirm mode: request user approval for write operations
if (permissionMode === "confirm" && WRITE_METHODS.has(method)) {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
if (!approved) {
return { ok: false, error: "Operation denied by user." };
}
}
// Scope validation for session-targeted operations
// Validate session scope *first* so out-of-scope callers cannot infer the
// existence or activity of foreign sessions through busy-state error
// messages, and so requests fail fast without blocking the write lock.
if (method !== "netcatty/getContext" && params?.sessionId) {
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId, params?.scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
switch (method) {
case "netcatty/getContext":
return handleGetContext(params);
case "netcatty/exec":
return handleExec(params);
default:
throw new Error(`Unknown method: ${method}`);
if ((method === "netcatty/exec" || method === "netcatty/jobStart") && params?.sessionId) {
const busy = getSessionBusyError(params.sessionId);
if (busy) return busy;
}
if (sessionWriteLockId) {
const pendingMethod = pendingSessionWriteApprovals.get(sessionWriteLockId);
if (pendingMethod) {
return {
ok: false,
error: "Session already has another command request awaiting approval or startup. Wait for it to finish before starting a new command.",
};
}
pendingSessionWriteApprovals.set(sessionWriteLockId, method);
}
try {
// Confirm mode: request user approval for write operations.
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
// must remain available even if the renderer is unavailable; otherwise
// a runaway terminal_start job could not be interrupted at all.
if (permissionMode === "confirm" && WRITE_METHODS.has(method) && method !== "netcatty/jobStop") {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
if (!approved) {
return { ok: false, error: "Operation denied by user." };
}
}
switch (method) {
case "netcatty/getContext":
return handleGetContext(params);
case "netcatty/exec":
return handleExec(params);
case "netcatty/jobStart":
return handleJobStart(params);
case "netcatty/jobPoll":
return handleJobPoll(params);
case "netcatty/jobStop":
return handleJobStop(params);
default:
throw new Error(`Unknown method: ${method}`);
}
} finally {
if (sessionWriteLockId) {
pendingSessionWriteApprovals.delete(sessionWriteLockId);
}
}
}
@@ -526,7 +797,7 @@ function handleGetContext(params) {
// ── Handler: exec ──
function handleExec(params) {
function resolveExecContext(params) {
const { sessionId, command } = params;
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== 'string' || !command.trim()) {
@@ -574,60 +845,296 @@ function handleExec(params) {
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
return {
ok: true,
context: {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
},
};
}
function handleExec(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
} = resolved.context;
const reservation = reserveSessionExecution(sessionId, "exec");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const runExecution = (factory) => {
try {
return Promise.resolve(factory()).finally(() => {
releaseSessionExecution(sessionId, sessionToken);
});
} catch (err) {
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
}
};
// 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, {
return runExecution(() => 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, {
return runExecution(() => execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
expectedPrompt: session.lastIdlePrompt || "",
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
});
// MCP callers have terminal_start as a fallback for long commands,
// so enforce a hard wall-clock timeout here to match the MCP budget.
enforceWallTimeout: true,
}));
}
// 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) {
releaseSessionExecution(sessionId, sessionToken);
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") {
return execViaChannel(sshClient, command, {
return runExecution(() => execViaChannel(sshClient, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
});
// Pass chatSessionId so cancelPtyExecsForSession can interrupt this
// exec channel when the originating ACP run is stopped.
chatSessionId: params?.chatSessionId,
}));
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
return execViaRawPty(session.serialPort, command, {
return runExecution(() => execViaRawPty(session.serialPort, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: session.serialEncoding || "utf8",
});
}));
}
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: "Session does not support command execution" };
}
function handleJobStart(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
isNetworkDevice,
sessionProtocol,
ptyStream,
} = resolved.context;
if (isNetworkDevice || sessionProtocol === "serial") {
return {
ok: false,
error: "Background execution currently supports shell-backed PTY sessions only.",
};
}
if (!ptyStream || typeof ptyStream.write !== "function") {
return {
ok: false,
error: "Background execution requires a writable PTY-backed terminal session.",
};
}
const reservation = reserveSessionExecution(sessionId, "job");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const jobId = createBackgroundJobId();
const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS);
let handle;
try {
handle = startPtyJob(ptyStream, command, {
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
// are designed to survive ACP "Stop" so the model can stop polling
// without aborting a long-running build/scan/log stream. The job is
// managed via terminal_stop and the per-session execution lock.
timeoutMs,
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
normalizeFinalOutput: false,
});
} catch (err) {
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
}
const startedAt = Date.now();
const job = {
id: jobId,
sessionId,
chatSessionId: chatSessionId || null,
command,
status: "running",
startedAt,
updatedAt: startedAt,
exitCode: null,
error: null,
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
handle,
};
backgroundJobs.set(jobId, job);
handle.resultPromise.then((result) => {
job.updatedAt = Date.now();
job.exitCode = result.exitCode ?? null;
storeCompletedJobOutput(job, result.stdout || "", result);
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
if (result.error === "Cancelled" || isForcedCancel) {
// Forced cancel means the process ignored SIGINT for the cancel
// wall-clock window. We mark the job as cancelled and release the
// lock so the session is reusable; the error message tells the
// caller the process may still be running so subsequent commands
// should be considered carefully. This is consistent: callers see
// completed=true exactly when the lock is no longer held.
job.status = "cancelled";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
if (result.error) {
job.status = "failed";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
// A non-zero exit code without an error message still represents a
// failed command (e.g. a build/test that returned 1). Mark it as failed
// so callers don't have to special-case exitCode against status.
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
job.status = "failed";
job.error = `Command exited with code ${result.exitCode}`;
releaseSessionExecution(sessionId, sessionToken);
return;
}
job.status = "completed";
releaseSessionExecution(sessionId, sessionToken);
}).catch((err) => {
job.updatedAt = Date.now();
job.status = "failed";
job.error = err?.message || String(err);
storeCompletedJobOutput(job, job.stdout || "");
releaseSessionExecution(sessionId, sessionToken);
});
return {
ok: true,
jobId,
sessionId,
command,
status: "running",
startedAt,
outputMode: "foreground-mirrored",
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
};
}
function getScopedJob(jobId, chatSessionId) {
const job = backgroundJobs.get(jobId);
if (!job) return null;
// Per-chat isolation: a job started under a chat session can only be
// accessed by callers presenting the same chatSessionId. Unscoped or
// statically-scoped callers cannot reach into another chat's jobs.
if (job.chatSessionId) {
if (!chatSessionId || job.chatSessionId !== chatSessionId) {
return null;
}
}
return job;
}
function handleJobPoll(params) {
const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// Re-check session scope so a caller that lost access to the host
// cannot continue reading output from jobs on that session.
// Covers dynamic (chatSessionId), static (scopedSessionIds), and global modes.
if (job.sessionId) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
return serializeBackgroundJob(job, offset);
}
function handleJobStop(params) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// For statically scoped MCP clients, validate that the job's session is
// within the caller's static scope so a foreign jobId cannot cancel jobs
// outside the caller's allowed sessions. Dynamic chat scope is already
// enforced by getScopedJob (caller's chatSessionId must match the job's),
// and we intentionally do NOT re-check dynamic scope here so jobs can
// still be stopped after workspace membership changes — otherwise the
// session lock would stay held forever.
if (Array.isArray(scopedSessionIds) && job.sessionId) {
if (!scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
}
if (job.status === "running") {
try {
job.handle?.cancel?.();
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
}
return serializeBackgroundJob(job, 0);
}
// ── MCP Server Config Builder ──
function resolveMcpServerRuntimeCommand() {
@@ -695,6 +1202,12 @@ function cleanupScopedMetadata(chatSessionId) {
if (chatSessionId) {
scopedMetadata.delete(chatSessionId);
cancelledChatSessions.delete(chatSessionId);
cancelBackgroundJobsForSession(chatSessionId);
// Resolve any in-flight approval requests so dispatch()'s finally block
// releases its pendingSessionWriteApprovals entry. Without this, a chat
// deleted while an approval was pending would leave the per-session
// write lock held until the 5-minute approval timeout.
clearPendingApprovals(chatSessionId);
}
}
@@ -705,6 +1218,15 @@ function cleanup() {
tcpPort = null;
}
scopedMetadata.clear();
for (const [, job] of backgroundJobs) {
try {
job.handle?.cancel?.();
} catch {
// Ignore cancellation failures during cleanup
}
}
backgroundJobs.clear();
activeSessionExecutions.clear();
}
module.exports = {
@@ -723,6 +1245,7 @@ module.exports = {
getOrCreateHost,
buildMcpServerConfig,
activePtyExecs,
cancelBackgroundJobsForSession,
cancelAllPtyExecs,
cancelPtyExecsForSession,
getSessionMeta,
@@ -731,4 +1254,7 @@ module.exports = {
setMainWindowGetter,
resolveApprovalFromRenderer,
clearPendingApprovals,
reserveSessionExecution,
releaseSessionExecution,
getSessionBusyError,
};

View File

@@ -44,6 +44,10 @@ const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cj
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
// Minimum window size: enough to render the expanded sidebar + a usable
// host list + the 420px host details / new-host aside panel without overflow.
const MIN_WINDOW_WIDTH = 1100;
const MIN_WINDOW_HEIGHT = 640;
function debugLog(...args) {
if (!DEBUG_WINDOWS) return;
@@ -626,9 +630,10 @@ async function createWindow(electronModule, options) {
};
if (savedState) {
// Use saved dimensions
windowBounds.width = savedState.width;
windowBounds.height = savedState.height;
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
@@ -658,6 +663,8 @@ async function createWindow(electronModule, options) {
const win = new BrowserWindow({
...windowBounds,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
backgroundColor,
icon: appIcon,
show: false,

View File

@@ -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 and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable.",
"Execute a short command on a Netcatty terminal session and wait for the full result. Use this only for commands expected to finish within about 60 seconds. For long-running commands such as builds, scans, log-following, or anything likely to exceed that budget, use terminal_start and then terminal_poll instead.",
{
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."),
@@ -242,6 +242,69 @@ server.tool(
},
);
server.tool(
"terminal_start",
"Start a long-running command on a Netcatty terminal session without waiting for final completion. The command still runs in the visible terminal/PTTY so the user can watch live output. Prefer this whenever the command may exceed about 2 minutes, or when it streams output for an extended period, such as builds, scans, watch commands, and log-follow commands. After starting, wait at least about 30 seconds before the first terminal_poll unless you have a strong reason to check sooner.",
{
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
command: z.string().describe("The command to start in the target session."),
},
async ({ sessionId, command }) => {
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/jobStart", { ...scopeParams, sessionId, command });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to start background command"}` }], isError: true };
}
return {
content: [{
type: "text",
text: JSON.stringify({
jobId: result.jobId,
sessionId: result.sessionId,
status: result.status,
startedAt: result.startedAt,
outputMode: result.outputMode,
recommendedPollIntervalMs: result.recommendedPollIntervalMs,
}, null, 2),
}],
};
},
);
server.tool(
"terminal_poll",
"Poll a long-running Netcatty command that was started with terminal_start. Returns incremental output since the given offset and the current status. Use the returned nextOffset for the next poll. If outputTruncated is true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls unless the tool output explicitly justifies checking sooner. As soon as completed is true, stop polling and analyze the final result immediately.",
{
jobId: z.string().describe("The background job ID returned by terminal_start."),
offset: z.number().int().min(0).optional().describe("Character offset previously returned as nextOffset. Omit or use 0 on the first poll."),
},
async ({ jobId, offset }) => {
const result = await rpcCall("netcatty/jobPoll", { ...scopeParams, jobId, offset: offset || 0 });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to poll background command"}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
server.tool(
"terminal_stop",
"Stop a long-running Netcatty command that was started with terminal_start. This sends Ctrl+C to the running terminal job and returns its latest state.",
{
jobId: z.string().describe("The background job ID returned by terminal_start."),
},
async ({ jobId }) => {
const result = await rpcCall("netcatty/jobStop", { ...scopeParams, jobId });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error || "Failed to stop background command"}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
// ── Start ──
async function main() {

View File

@@ -102,6 +102,29 @@
}
}
@keyframes split-panel-enter {
0% {
width: 0;
min-width: 0;
opacity: 0;
transform: translateX(22px);
}
55% {
opacity: 0.88;
}
100% {
width: var(--aside-inline-width);
min-width: var(--aside-inline-width);
opacity: 1;
transform: translateX(0);
}
}
.split-panel-enter {
animation: split-panel-enter 220ms cubic-bezier(0.24, 0.84, 0.32, 1) both;
will-change: width, opacity, transform;
}
:root {
color-scheme: light;
}