Compare commits

...

59 Commits

Author SHA1 Message Date
bincxz
f77c2b2de9 fix: resolve ESLint errors blocking dev startup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Add release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: notify subscribers after custom theme store reload

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

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

---------

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

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

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

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

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

* fix: address review feedback on auto-update toggle

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Related: #264

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

Related: #264

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

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

Fixes #343

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Misc:
- Remove debug console.log statements in mcpServerBridge

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:22:28 +08:00
105 changed files with 18108 additions and 201 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -292,10 +293,12 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
});
@@ -320,6 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(

View File

@@ -50,6 +50,7 @@ const en: Messages = {
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -114,6 +115,8 @@ const en: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -141,6 +144,8 @@ const en: Messages = {
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
@@ -834,8 +839,8 @@ const en: Messages = {
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
@@ -1478,6 +1483,148 @@ const en: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
'ai.chat.recommendAllow': 'Allow',
'ai.chat.recommendConfirm': 'Confirm',
'ai.chat.recommendDeny': 'Deny',
'ai.chat.exportConversation': 'Export conversation',
'ai.chat.exportAs': 'Export As',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Plain Text',
'ai.chat.thinking': 'Thinking',
'ai.chat.thoughtFor': 'Thought for {duration}',
'ai.chat.thought': 'Thought',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': 'Detected on this machine',
'ai.chat.rescan': 'Re-scan',
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before actions',
'ai.chat.permAutoDesc': 'Execute freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
};
export default en;

View File

@@ -37,6 +37,7 @@ const zhCN: Messages = {
// Dialogs / prompts
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
@@ -98,6 +99,8 @@ const zhCN: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -125,6 +128,8 @@ const zhCN: Messages = {
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
@@ -544,8 +549,8 @@ const zhCN: Messages = {
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
@@ -1493,6 +1498,148 @@ const zhCN: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
'ai.providers.apiKeyConfigured': 'API Key 已配置',
'ai.providers.noApiKey': '未设置 API Key',
'ai.providers.configure': '配置',
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key将作为 CODEX_API_KEY 传递)。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '检查',
'ai.codex.openLogin': '打开登录',
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty内置',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
'ai.chat.permissionRequired': '需要权限',
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
'ai.chat.recommendAllow': '允许',
'ai.chat.recommendConfirm': '确认',
'ai.chat.recommendDeny': '拒绝',
'ai.chat.exportConversation': '导出对话',
'ai.chat.exportAs': '导出为',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '纯文本',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本机检测到',
'ai.chat.rescan': '重新扫描',
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自主',
'ai.chat.permObserverDesc': '只读模式',
'ai.chat.permConfirmDesc': '操作前询问',
'ai.chat.permAutoDesc': '自由执行',
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
};
export default zhCN;

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
@@ -31,7 +32,9 @@ interface AutoSyncConfig {
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
}
@@ -97,13 +100,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}, [getSyncSnapshot]);
// Create a hash of current data for comparison
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
return JSON.stringify(getSyncSnapshot());
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
@@ -255,7 +259,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Circle, FolderTree, LayoutGrid, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
@@ -15,13 +15,15 @@ import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { AIChatSidePanel } from './AIChatSidePanel';
import { useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
type SidePanelTab = 'sftp' | 'scripts' | 'theme';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
type WorkspaceRect = { x: number; y: number; w: number; h: number };
@@ -241,7 +243,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
const [sidePanelWidth, setSidePanelWidth] = useState(320);
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
const stored = window.localStorage.getItem('netcatty_side_panel_width');
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
});
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
@@ -373,13 +378,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const startX = e.clientX;
const startWidth = sidePanelWidth;
let lastWidth = startWidth;
const onMouseMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const newWidth = Math.max(200, Math.min(600, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
setSidePanelWidth(newWidth);
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
setSidePanelWidth(lastWidth);
};
const onMouseUp = () => {
sftpResizingRef.current = false;
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
@@ -844,6 +851,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSwitchSidePanelTab('theme');
}, [handleSwitchSidePanelTab]);
// Open AI chat side panel
const handleOpenAI = useCallback(() => {
handleSwitchSidePanelTab('ai');
}, [handleSwitchSidePanelTab]);
// Listen for global AI panel toggle (from TopTabs button)
useEffect(() => {
const handler = () => handleOpenAI();
window.addEventListener('netcatty:toggle-ai-panel', handler);
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
}, [handleOpenAI]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
@@ -906,6 +925,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
// AI Chat state
const aiState = useAIState();
const { cleanupOrphanedSessions } = aiState;
// On mount: clean up orphaned AI sessions after a short delay
// (allows sessions/workspaces to fully initialize)
const hasCleanedUpRef = useRef(false);
useEffect(() => {
if (hasCleanedUpRef.current) return;
// Guard: wait until both sessions AND workspaces have loaded to avoid
// racing with partial state (e.g. sessions loaded but workspaces not yet).
if (sessions.length === 0 || workspaces.length === 0) return;
hasCleanedUpRef.current = true;
const activeIds = new Set<string>();
for (const s of sessions) activeIds.add(s.id);
for (const w of workspaces) activeIds.add(w.id);
cleanupOrphanedSessions(activeIds);
}, [sessions, workspaces, cleanupOrphanedSessions]);
// Build terminal session context for the AI chat panel
const aiTerminalSessions = useMemo(() => {
const sessionIds = activeWorkspace?.root
? collectSessionIds(activeWorkspace.root)
: activeSession ? [activeSession.id] : [];
const result = sessionIds.map(sid => {
const s = sessions.find(s => s.id === sid);
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
return {
sessionId: sid,
hostId: s?.hostId || '',
hostname: host?.hostname || '',
label: host?.label || s?.hostLabel || '',
os: host?.os,
username: host?.username,
connected: s?.status === 'connected',
};
});
return result;
}, [sessions, hosts, activeWorkspace, activeSession]);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -1117,12 +1177,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
)}
>
{isSidePanelOpenForCurrentTab && (
<div className="flex h-8 items-center px-1.5 py-0.5 flex-shrink-0 gap-0.5">
<div className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'sftp'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
@@ -1131,13 +1191,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onClick={handleToggleSftpFromBar}
title="SFTP"
>
<FolderTree size={14} />
<FolderTree size={15} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'scripts'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
@@ -1146,13 +1206,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onClick={handleOpenScripts}
title="Scripts"
>
<Zap size={14} />
<Zap size={15} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'theme'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
@@ -1161,32 +1221,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onClick={handleOpenTheme}
title="Theme"
>
<Palette size={14} />
<Palette size={15} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'ai'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
onClick={handleOpenAI}
title="AI Chat"
>
<MessageSquare size={15} />
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0 text-muted-foreground",
"h-7 w-7 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
>
{sidePanelPosition === 'left' ? <PanelRight size={14} /> : <PanelLeft size={14} />}
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0 text-muted-foreground",
"h-7 w-7 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
onClick={handleCloseSidePanel}
title="Close panel"
>
<X size={14} />
<X size={15} />
</Button>
</div>
)}
@@ -1247,6 +1322,46 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
/>
</div>
)}
{/* AI Chat sub-panel */}
{activeSidePanelTab === 'ai' && (
<div className="absolute inset-0 z-10">
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
setActiveSessionId={aiState.setActiveSessionId}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
scopeHostIds={activeWorkspace?.root
? collectSessionIds(activeWorkspace.root).map(sid => {
const s = sessions.find(s => s.id === sid);
return s?.hostId;
}).filter((id): id is string => !!id)
: activeSession?.hostId ? [activeSession.hostId] : []
}
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
terminalSessions={aiTerminalSessions}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { LogView } from '../application/state/useSessionState';
@@ -747,6 +747,15 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Bell size={16} />
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export default function SettingsSyncTab(props: {
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;
}) {
const {
vault,
@@ -22,6 +23,7 @@ export default function SettingsSyncTab(props: {
importDataFromString,
importPortForwardingRules,
clearVaultData,
onSettingsApplied,
} = props;
const onBuildPayload = useCallback((): SyncPayload => {
@@ -56,9 +58,10 @@ export default function SettingsSyncTab(props: {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString, importPortForwardingRules],
[importDataFromString, importPortForwardingRules, onSettingsApplied],
);
const clearAllLocalData = useCallback(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -172,13 +172,30 @@ export interface SyncPayload {
// Settings
settings?: {
// Theme & Appearance
theme?: 'light' | 'dark' | 'system';
accentColor?: string;
lightUiThemeId?: string;
darkUiThemeId?: string;
accentMode?: 'theme' | 'custom';
customAccent?: string;
uiFontFamilyId?: string;
uiLanguage?: string;
customCSS?: string;
// Terminal
terminalTheme?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
hotkeyScheme?: string;
terminalSettings?: Record<string, unknown>;
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
// Keyboard
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
// Editor
editorWordWrap?: boolean;
// SFTP
sftpDoubleClickBehavior?: 'open' | 'transfer';
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
};
// Sync metadata

View File

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

View File

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

View File

@@ -0,0 +1,209 @@
/**
* Codex-related helper functions and state.
*
* Manages Codex login sessions, auth validation cache, binary resolution,
* integration state normalization, and error / fingerprint utilities.
*/
"use strict";
const { execFileSync } = require("node:child_process");
const { createHash } = require("node:crypto");
const { existsSync } = require("node:fs");
const path = require("node:path");
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
// ── Module-level state ──
const codexLoginSessions = new Map();
let codexValidationCache = null;
const CODEX_AUTH_HINTS = [
"not logged in",
"authentication required",
"auth required",
"login required",
"missing credentials",
"no credentials",
"unauthorized",
"forbidden",
"codex login",
"401",
"403",
"invalid_grant",
"invalid_token",
"credentials",
];
// ── Package / binary resolution ──
function getCodexPackageName() {
const key = `${process.platform}-${process.arch}`;
switch (key) {
case "darwin-arm64":
return "@zed-industries/codex-acp-darwin-arm64";
case "darwin-x64":
return "@zed-industries/codex-acp-darwin-x64";
case "linux-arm64":
return "@zed-industries/codex-acp-linux-arm64";
case "linux-x64":
return "@zed-industries/codex-acp-linux-x64";
case "win32-arm64":
return "@zed-industries/codex-acp-win32-arm64";
case "win32-x64":
return "@zed-industries/codex-acp-win32-x64";
default:
return null;
}
}
function resolveCodexAcpBinaryPath(shellEnv, electronModule) {
const binaryName = process.platform === "win32" ? "codex-acp.exe" : "codex-acp";
const isPackaged = electronModule?.app?.isPackaged;
// Dev mode: prefer system PATH
if (!isPackaged && shellEnv) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const systemPath = execFileSync(whichCmd, [binaryName], {
encoding: "utf8",
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
env: shellEnv,
}).trim().split("\n")[0].trim();
if (systemPath && existsSync(systemPath)) {
return systemPath;
}
} catch {
// Not on PATH
}
}
// Packaged build (or dev fallback): use npm-bundled binary
try {
const pkgName = getCodexPackageName();
if (!pkgName) return binaryName;
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;
}
}
// ── Login session helpers ──
function appendCodexLoginOutput(session, chunk) {
const cleanChunk = stripAnsi(chunk);
if (!cleanChunk) return;
session.output += cleanChunk;
if (!session.url) {
session.url = extractFirstNonLocalhostUrl(session.output);
}
}
function toCodexLoginSessionResponse(session) {
return {
sessionId: session.id,
state: session.state,
url: session.url,
output: session.output,
error: session.error,
exitCode: session.exitCode,
};
}
function getActiveCodexLoginSession() {
for (const session of codexLoginSessions.values()) {
if (session.state === "running" && session.process && !session.process.killed) {
return session;
}
}
return null;
}
// ── Integration state ──
function normalizeCodexIntegrationState(rawOutput) {
const normalizedOutput = String(rawOutput || "").toLowerCase();
if (normalizedOutput.includes("logged in using chatgpt")) {
return "connected_chatgpt";
}
if (
normalizedOutput.includes("logged in using an api key") ||
normalizedOutput.includes("logged in using api key")
) {
return "connected_api_key";
}
if (normalizedOutput.includes("not logged in")) {
return "not_logged_in";
}
return "unknown";
}
// ── Error helpers ──
function extractCodexError(error) {
const message =
error?.data?.message ||
error?.errorText ||
error?.message ||
error?.error ||
String(error);
const code = error?.data?.code || error?.code;
return {
message: typeof message === "string" ? message : String(message),
code: typeof code === "string" ? code : undefined,
};
}
function isCodexAuthError(params) {
const searchableText = `${params?.code || ""} ${params?.message || ""}`.toLowerCase();
return CODEX_AUTH_HINTS.some((hint) => searchableText.includes(hint));
}
// ── Fingerprints ──
function getCodexAuthFingerprint(apiKey) {
const normalized = String(apiKey || "").trim();
if (!normalized) return null;
return createHash("sha256").update(normalized).digest("hex");
}
function getCodexMcpFingerprint(mcpServers) {
return createHash("sha256").update(JSON.stringify(mcpServers || [])).digest("hex");
}
// ── Validation cache ──
function invalidateCodexValidationCache() {
codexValidationCache = null;
}
function getCodexValidationCache() {
return codexValidationCache;
}
function setCodexValidationCache(value) {
codexValidationCache = value;
}
module.exports = {
codexLoginSessions,
getCodexPackageName,
resolveCodexAcpBinaryPath,
appendCodexLoginOutput,
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
getCodexMcpFingerprint,
invalidateCodexValidationCache,
getCodexValidationCache,
setCodexValidationCache,
};

View File

@@ -0,0 +1,197 @@
/**
* PTY and SSH channel command execution.
*
* Provides a unified `execViaPty` that works for both MCP server bridge
* (tracking in activePtyExecs for cancellation) and Catty Agent
* (stripping MCP markers from output).
*
* Also provides `execViaChannel` for SSH exec channel fallback.
*/
"use strict";
const crypto = require("crypto");
const { stripAnsi } = require("./shellUtils.cjs");
/**
* Execute command through a terminal PTY stream.
* The user sees the command typed and output in their terminal.
* Uses a unique marker to detect when the command finishes and capture the exit code.
*
* @param {object} ptyStream - The PTY stream to write to
* @param {string} command - The command to execute
* @param {object} [options]
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
*/
function execViaPty(ptyStream, command, options) {
const {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let timeoutId = null;
let finished = false;
const onData = (data) => {
const text = data.toString();
if (!foundStart) {
// Look for the start marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const startMarker = marker + "_S";
let pos = 0;
while (pos < text.length) {
const idx = text.indexOf(startMarker, pos);
if (idx === -1) break;
// Accept if at start of text, or preceded by \n or \r (line boundary)
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
foundStart = true;
const afterMarker = text.slice(idx);
const nlIdx = afterMarker.indexOf("\n");
if (nlIdx !== -1) {
output += afterMarker.slice(nlIdx + 1);
}
break;
}
pos = idx + 1;
}
if (foundStart) checkEnd();
return;
}
output += text;
checkEnd();
};
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const endPattern = marker + "_E:";
let searchFrom = 0;
while (searchFrom < output.length) {
const endIdx = output.indexOf(endPattern, searchFrom);
if (endIdx === -1) return;
// Accept if at start of output, or preceded by \n or \r (line boundary)
if (endIdx === 0 || output[endIdx - 1] === '\n' || output[endIdx - 1] === '\r') {
const afterEnd = output.slice(endIdx + endPattern.length);
const codeMatch = afterEnd.match(/^(\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
const stdout = output.slice(0, endIdx);
finish(stdout, exitCode);
return;
}
searchFrom = endIdx + 1;
}
}
function finish(stdout, exitCode) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
ptyStream.removeListener("data", onData);
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").trim();
if (stripMarkers) {
cleaned = cleaned.replace(/__NCMCP_[^\r\n]*[\r\n]*/g, "").trim();
}
resolve({
ok: exitCode === 0 || exitCode === null,
stdout: cleaned,
stderr: "",
exitCode: exitCode ?? 0,
});
}
timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
ptyStream.removeListener("data", onData);
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
// Send Ctrl+C to kill the timed-out command
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
const cleaned = stripAnsi(output).trim();
const timeoutSec = Math.round(timeoutMs / 1000);
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
}, timeoutMs);
ptyStream.on("data", onData);
// Register for cancellation if tracking map provided
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
cleanup: () => { clearTimeout(timeoutId); ptyStream.removeListener("data", onData); },
});
}
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
ptyStream.write(
`printf '${marker}_S\\n';${noPager}${command}\n` +
`__nc=$?;printf '${marker}_E:'$__nc'\\n';(exit $__nc)\n`
);
});
}
/**
* Fallback: execute via a separate SSH exec channel (invisible to terminal).
*
* @param {object} sshClient - SSH client with .exec() method
* @param {string} command - The command to execute
* @param {object} [options]
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
*/
function execViaChannel(sshClient, command, options) {
const { timeoutMs = 60000 } = options || {};
return new Promise((resolve) => {
sshClient.exec(command, (err, execStream) => {
if (err) {
resolve({ ok: false, error: err.message });
return;
}
if (!execStream) {
resolve({ ok: false, output: 'Failed to create exec stream', exitCode: 1 });
return;
}
let stdout = "";
let stderr = "";
let finished = false;
const timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
try { execStream.close(); } catch { /* ignore */ }
const timeoutSec = Math.round(timeoutMs / 1000);
resolve({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
}, timeoutMs);
execStream.on("data", (data) => { stdout += data.toString(); });
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
execStream.on("close", (code) => {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
});
});
});
}
module.exports = {
execViaPty,
execViaChannel,
stripAnsi,
};

View File

@@ -0,0 +1,191 @@
/**
* Shell utility functions shared across AI bridge modules.
*
* Provides ANSI stripping, URL extraction, CLI resolution, path helpers,
* stream chunk serialization, and cached shell environment resolution.
*/
"use strict";
const { execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const path = require("node:path");
// ── ANSI / URL regexes ──
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
// ── ANSI stripping ──
function stripAnsi(input) {
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
const normalized = String(hostname || "").trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "[::1]" ||
normalized.endsWith(".localhost")
);
}
function extractFirstNonLocalhostUrl(output) {
const { URL } = require("node:url");
const matches = stripAnsi(output).match(URL_CANDIDATE_REGEX);
if (!matches) return null;
for (const match of matches) {
try {
const parsedUrl = new URL(match.trim().replace(/[),.;!?]+$/, ""));
if (!isLocalhostHostname(parsedUrl.hostname)) {
return parsedUrl.toString();
}
} catch {
// Ignore invalid URL candidates.
}
}
return null;
}
// ── CLI / path helpers ──
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
return null;
}
if (shellEnv) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const resolved = execFileSync(whichCmd, [command], {
encoding: "utf8",
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
env: shellEnv,
}).trim().split("\n")[0].trim();
if (resolved && existsSync(resolved)) return resolved;
} catch {
// Not found on PATH
}
}
return null;
}
function toUnpackedAsarPath(filePath) {
const unpackedPath = filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
if (unpackedPath !== filePath && existsSync(unpackedPath)) {
return unpackedPath;
}
return filePath;
}
// ── Shell environment (cached) ──
let _cachedShellEnv = null;
async function getShellEnv() {
if (_cachedShellEnv) return _cachedShellEnv;
const home = process.env.HOME || "";
const extraPaths = [
`${home}/.local/bin`,
`${home}/.npm-global/bin`,
"/usr/local/bin",
"/opt/homebrew/bin",
];
if (process.platform === "win32") {
_cachedShellEnv = {
...process.env,
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
};
return _cachedShellEnv;
}
// On macOS/Linux, spawn a login shell to capture the real environment.
try {
const shell = process.env.SHELL || "/bin/zsh";
const envOutput = execFileSync(shell, ['-ilc', 'env'], {
encoding: "utf8",
timeout: 10000,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, HOME: home },
});
const envMap = {};
for (const line of envOutput.split("\n")) {
const idx = line.indexOf("=");
if (idx > 0) {
envMap[line.slice(0, idx)] = line.slice(idx + 1);
}
}
const shellPath = envMap.PATH || "";
_cachedShellEnv = {
...envMap,
...process.env,
PATH: [...extraPaths, shellPath, process.env.PATH || ""].join(path.delimiter),
};
} catch {
_cachedShellEnv = {
...process.env,
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
};
}
return _cachedShellEnv;
}
// ── Stream chunk serialization ──
function serializeStreamChunk(chunk) {
if (!chunk || !chunk.type) return null;
switch (chunk.type) {
case "text-delta":
return { type: "text-delta", textDelta: chunk.text ?? chunk.textDelta ?? "" };
case "reasoning-delta":
return { type: "reasoning-delta", delta: chunk.text ?? chunk.delta ?? "" };
case "reasoning-start":
return { type: "reasoning-start", id: chunk.id ?? undefined };
case "reasoning-end":
return { type: "reasoning-end", id: chunk.id ?? undefined };
case "tool-call":
return {
type: "tool-call",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: chunk.args,
};
case "tool-result":
return {
type: "tool-result",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
result: chunk.result,
output: chunk.output,
};
case "error":
return { type: "error", error: chunk.error };
default:
try {
return JSON.parse(JSON.stringify(chunk));
} catch {
return { type: chunk.type };
}
}
}
module.exports = {
stripAnsi,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
resolveCliFromPath,
toUnpackedAsarPath,
getShellEnv,
serializeStreamChunk,
};

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,40 @@
let _deps = null;
/**
* Read the persisted auto-update preference from a JSON file in userData.
* Returns true (default) if the file doesn't exist or is unreadable.
*/
function readAutoUpdatePreference() {
try {
const { app } = _deps?.electronModule || {};
if (!app) return true;
const path = require('path');
const fs = require('fs');
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
return data.enabled !== false;
} catch {
return true; // default to enabled
}
}
/**
* Persist the auto-update preference to a JSON file in userData.
*/
function writeAutoUpdatePreference(enabled) {
try {
const { app } = _deps?.electronModule || {};
if (!app) return;
const path = require('path');
const fs = require('fs');
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
} catch (err) {
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
}
}
/**
* Returns true when the current packaging format supports electron-updater
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
@@ -51,7 +85,7 @@ function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = true;
autoUpdater.autoDownload = readAutoUpdatePreference();
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
@@ -84,9 +118,12 @@ function setupGlobalListeners() {
updater.on("update-available", (info) => {
_isChecking = false;
// autoDownload=true means the download begins immediately after this event
_isDownloading = true;
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
// Only track as downloading when autoDownload is enabled — otherwise no
// download will actually start and the status would be stuck at 0%.
// Use 'available' so late-opening windows can still hydrate the version.
const willDownload = updater.autoDownload !== false;
_isDownloading = willDownload;
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-available", {
version: info.version || "",
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
@@ -144,6 +181,9 @@ function startAutoCheck(delayMs = 5000) {
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
return;
}
// Cancel any existing timer to avoid duplicate concurrent checks
// (e.g. from multiple windows initializing or re-enable toggle).
cancelAutoCheck();
_autoCheckTimer = setTimeout(async () => {
_autoCheckTimer = null;
const updater = getAutoUpdater();
@@ -151,6 +191,12 @@ function startAutoCheck(delayMs = 5000) {
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
return;
}
// Respect autoDownload flag — the renderer may have disabled it via IPC
// before this timer fires.
if (updater.autoDownload === false) {
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
return;
}
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
try {
@@ -317,6 +363,34 @@ function registerHandlers(ipcMain) {
updater.quitAndInstall(false, true);
});
// ---- Get auto-update preference -----------------------------------------
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
return { enabled: readAutoUpdatePreference() };
});
// ---- Enable/disable auto-update ----------------------------------------
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
const wasEnabled = _prevAutoDownloadEnabled;
_prevAutoDownloadEnabled = !!enabled;
const updater = getAutoUpdater();
if (updater) {
updater.autoDownload = !!enabled;
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
}
// Persist so the preference survives app restarts
writeAutoUpdatePreference(!!enabled);
if (!enabled) {
cancelAutoCheck();
} else if (!wasEnabled && !_isChecking) {
// Only re-schedule when actually re-enabling (not on every mount sync),
// to avoid duplicate checks from multiple windows initializing.
// Skip if a check is already in flight to prevent concurrent calls.
startAutoCheck(2000);
}
return { success: true };
});
console.log("[AutoUpdate] Handlers registered");
}

View File

@@ -0,0 +1,813 @@
/**
* MCP Server Bridge — TCP host in Electron main process
*
* Starts a local TCP server that the netcatty-mcp-server.cjs child process
* connects to. Handles JSON-RPC calls by dispatching to real SSH sessions
* and SFTP clients.
*/
"use strict";
const net = require("node:net");
const crypto = require("node:crypto");
const path = require("node:path");
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
let sessions = null; // Map<sessionId, { sshClient, stream, pty, conn, ... }>
let sftpClients = null; // Map<sftpId, SFTPWrapper>
let tcpServer = null;
let tcpPort = null;
let authToken = null; // Random token generated when TCP server starts
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
/**
* Safely quote a string for use in a POSIX shell command.
* Wraps the value in single quotes and escapes any embedded single quotes.
*/
function shellQuote(s) {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
// Each chat session only sees the hosts registered for its scope.
const scopedMetadata = new Map();
// Fallback: last-registered scope (used when no chatSessionId is provided)
let fallbackScopedSessionIds = [];
// Command safety checking (reuse from aiBridge)
let commandBlocklist = [];
// Cached compiled RegExp objects for commandBlocklist (rebuilt when blocklist changes)
let compiledBlocklist = [];
// Command timeout in milliseconds (default 60s, synced from user settings)
let commandTimeoutMs = 60000;
// Max iterations for AI agent loops (default 20, synced from user settings)
let maxIterations = 20;
// Permission mode: 'observer' | 'confirm' | 'autonomous' (synced from user settings)
let permissionMode = "confirm";
// Track active PTY executions for cancellation
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
function cancelAllPtyExecs() {
for (const [marker, entry] of activePtyExecs) {
try {
entry.cleanup();
// Send Ctrl+C to kill the running command
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
entry.ptyStream.write("\x03");
}
} catch { /* ignore */ }
}
activePtyExecs.clear();
}
function init(deps) {
sessions = deps.sessions;
sftpClients = deps.sftpClients;
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
}
function setCommandBlocklist(list) {
commandBlocklist = list || [];
// Recompile cached regexes when blocklist changes
compiledBlocklist = [];
for (const pattern of commandBlocklist) {
try {
compiledBlocklist.push(new RegExp(pattern, "i"));
} catch {
compiledBlocklist.push(null); // placeholder for invalid patterns
}
}
}
function setCommandTimeout(seconds) {
commandTimeoutMs = Math.max(1, Math.min(3600, seconds || 60)) * 1000;
}
function getCommandTimeoutMs() {
return commandTimeoutMs;
}
function setMaxIterations(value) {
maxIterations = Math.max(1, Math.min(100, value || 20));
}
function getMaxIterations() {
return maxIterations;
}
function setPermissionMode(mode) {
if (mode === "observer" || mode === "confirm" || mode === "autonomous") {
permissionMode = mode;
}
}
function getPermissionMode() {
return permissionMode;
}
/**
* Register metadata for terminal sessions (called from renderer via IPC).
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
* only see their own hosts.
* @param {Array<{sessionId, hostname, label, os, username, connected}>} sessionList
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
*/
function updateSessionMetadata(sessionList, chatSessionId) {
const ids = sessionList.map(s => s.sessionId);
const metaMap = new Map();
for (const s of sessionList) {
metaMap.set(s.sessionId, {
hostname: s.hostname || "",
label: s.label || "",
os: s.os || "",
username: s.username || "",
connected: s.connected !== false,
});
}
// Store per-scope metadata when chatSessionId is provided
if (chatSessionId) {
scopedMetadata.set(chatSessionId, { sessionIds: ids, metadata: metaMap });
} else {
// Only update fallback when no chatSessionId — prevents scoped updates from
// leaking all sessions to unscoped agents
fallbackScopedSessionIds = ids.slice();
}
}
/**
* Get scoped session IDs. If chatSessionId is provided, returns IDs for that
* specific scope; otherwise returns the last-registered fallback.
*/
function getScopedSessionIds(chatSessionId) {
if (chatSessionId) {
const scoped = scopedMetadata.get(chatSessionId);
if (scoped) return scoped.sessionIds;
}
return fallbackScopedSessionIds;
}
/**
* Look up metadata for a sessionId, scoped to a specific chat session.
* Falls back to session object properties if no scoped metadata is found.
*/
function getSessionMeta(sessionId, chatSessionId) {
// Try scoped metadata first
if (chatSessionId) {
const scoped = scopedMetadata.get(chatSessionId);
if (scoped?.metadata?.has(sessionId)) return scoped.metadata.get(sessionId);
}
// Fallback: check all scopes for this sessionId (backwards compat)
for (const [, scope] of scopedMetadata) {
if (scope.metadata?.has(sessionId)) return scope.metadata.get(sessionId);
}
return null;
}
/**
* Run an array of async task factories with a concurrency limit.
*/
async function limitConcurrency(tasks, limit) {
const results = [];
const executing = new Set();
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const p = task().then(r => { results[i] = r; }).finally(() => executing.delete(p));
executing.add(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
function checkCommandSafety(command) {
for (let i = 0; i < compiledBlocklist.length; i++) {
const re = compiledBlocklist[i];
if (re && re.test(command)) {
return { blocked: true, matchedPattern: commandBlocklist[i] };
}
}
return { blocked: false };
}
// ── TCP Server ──
function getOrCreateHost() {
if (tcpServer && tcpPort) return Promise.resolve(tcpPort);
// Generate a random auth token for this server instance
authToken = crypto.randomBytes(32).toString("hex");
return new Promise((resolve, reject) => {
const server = net.createServer((socket) => {
handleConnection(socket);
});
server.listen(0, "127.0.0.1", () => {
tcpPort = server.address().port;
tcpServer = server;
resolve(tcpPort);
});
server.on("error", (err) => {
console.error("[MCP Bridge] TCP server error:", err.message);
reject(err);
});
});
}
const MAX_TCP_BUFFER = 10 * 1024 * 1024; // 10MB
function handleConnection(socket) {
let buffer = "";
socket.setEncoding("utf-8");
socket.on("data", (chunk) => {
if (buffer.length + chunk.length > MAX_TCP_BUFFER) {
console.error("[MCP Bridge] TCP buffer exceeded max size, dropping connection");
socket.destroy();
return;
}
buffer += chunk;
let newlineIdx;
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIdx);
buffer = buffer.slice(newlineIdx + 1);
if (!line.trim()) continue;
handleMessage(socket, line);
}
});
socket.on("error", () => {
// Client disconnected — nothing to do
});
}
async function handleMessage(socket, line) {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
const { id, method, params } = msg;
if (id == null || !method) return;
// ── Authentication gate ──
// The first message from any connection MUST be auth/verify with the correct token.
// All other methods are rejected until the socket is authenticated.
if (!authenticatedSockets.has(socket)) {
if (method === "auth/verify" && params?.token === authToken) {
authenticatedSockets.add(socket);
const response = JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } }) + "\n";
if (!socket.destroyed) socket.write(response);
return;
}
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
}) + "\n";
if (!socket.destroyed) {
socket.write(response);
socket.destroy();
}
return;
}
try {
const result = await dispatch(method, params || {});
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
if (!socket.destroyed) socket.write(response);
} catch (err) {
const response = JSON.stringify({
jsonrpc: "2.0",
id,
error: { code: -32000, message: err?.message || String(err) },
}) + "\n";
if (!socket.destroyed) socket.write(response);
}
}
// ── RPC Dispatch ──
// Methods that modify remote state — blocked in observer mode
const WRITE_METHODS = new Set([
"netcatty/exec",
"netcatty/terminalWrite",
"netcatty/sftpWrite",
"netcatty/sftpMkdir",
"netcatty/sftpRemove",
"netcatty/sftpRename",
"netcatty/multiExec",
]);
/**
* Validate that a sessionId is allowed in the current scope.
* Checks both process-level SCOPED_SESSION_IDS and per-chatSession scoped metadata.
*/
function validateSessionScope(sessionId, chatSessionId) {
if (!sessionId) return null; // will fail at handler level
const scopedIds = getScopedSessionIds(chatSessionId);
if (scopedIds && scopedIds.length > 0 && !scopedIds.includes(sessionId)) {
return `Session "${sessionId}" is not in the current scope.`;
}
return null;
}
async function dispatch(method, params) {
// Observer mode: block all write operations
if (permissionMode === "observer" && WRITE_METHODS.has(method)) {
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.` };
}
// Scope validation for session-targeted operations
if (method !== "netcatty/getContext" && params?.sessionId) {
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
if (scopeErr) return { ok: false, error: scopeErr };
}
// For multi-exec, validate all session IDs
if (method === "netcatty/multiExec" && Array.isArray(params?.sessionIds)) {
for (const sid of params.sessionIds) {
const scopeErr = validateSessionScope(sid, params?.chatSessionId);
if (scopeErr) return { ok: false, error: scopeErr };
}
}
switch (method) {
case "netcatty/getContext":
return handleGetContext(params);
case "netcatty/exec":
return handleExec(params);
case "netcatty/terminalWrite":
return handleTerminalWrite(params);
case "netcatty/sftpList":
return handleSftpList(params);
case "netcatty/sftpRead":
return handleSftpRead(params);
case "netcatty/sftpWrite":
return handleSftpWrite(params);
case "netcatty/sftpMkdir":
return handleSftpMkdir(params);
case "netcatty/sftpRemove":
return handleSftpRemove(params);
case "netcatty/sftpRename":
return handleSftpRename(params);
case "netcatty/sftpStat":
return handleSftpStat(params);
case "netcatty/multiExec":
return handleMultiExec(params);
default:
throw new Error(`Unknown method: ${method}`);
}
}
// ── Handler: getContext ──
function handleGetContext(params) {
if (!sessions) return { hosts: [], instructions: "No sessions available." };
// Scope resolution: use explicit scopedSessionIds from MCP server env var (per-process, set at spawn).
// If scopedSessionIds is provided but empty, that means "no access" (not "all access").
// Only fall back to unscoped (show all) when scopedSessionIds is not provided at all.
const hasScopeParam = params?.scopedSessionIds != null;
const scopedIds = hasScopeParam
? new Set(params.scopedSessionIds)
: null;
// chatSessionId may be passed via env for per-scope metadata lookup
const chatSessionId = params?.chatSessionId || null;
const hosts = [];
// When scope param is provided (even if empty Set), enforce it strictly
if (hasScopeParam && scopedIds.size === 0) {
return {
environment: "netcatty-terminal",
description: "No hosts are available in the current scope.",
hosts: [],
hostCount: 0,
};
}
for (const [sessionId, session] of sessions.entries()) {
if (scopedIds && !scopedIds.has(sessionId)) continue;
// Only include SSH sessions (skip local terminal sessions)
const sshClient = session.conn || session.sshClient;
if (!sshClient || typeof sshClient.exec !== "function") continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
hosts.push({
sessionId,
hostname: meta.hostname || session.hostname || "",
label: meta.label || session.label || "",
os: meta.os || "",
username: meta.username || session.username || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn),
});
}
return {
environment: "netcatty-terminal",
description: "You are operating inside Netcatty, a multi-host SSH terminal manager. " +
"The user is managing remote servers. Use the provided tools to execute commands, " +
"read/write files, and manage hosts on the remote machines. " +
"Always prefer these tools over suggesting the user to do things manually.",
hosts,
hostCount: hosts.length,
};
}
// ── Handler: exec ──
function handleExec(params) {
const { sessionId, command } = params;
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== 'string' || !command.trim()) {
return { ok: false, error: 'Invalid command', exitCode: 1 };
}
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
const sshClient = session.conn || session.sshClient;
if (!sshClient || typeof sshClient.exec !== "function") {
return { ok: false, error: "Not an SSH session" };
}
const ptyStream = session.stream;
// If no PTY stream, fall back to exec channel (invisible to terminal)
if (!ptyStream || typeof ptyStream.write !== "function") {
return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs });
}
// Execute via PTY stream so user sees the command in the terminal
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
});
}
// ── Handler: terminalWrite ──
function handleTerminalWrite(params) {
const { sessionId, input } = params;
if (!sessionId || input == null) throw new Error("sessionId and input are required");
// Validate input against command blocklist
const safety = checkCommandSafety(input);
if (safety.blocked) {
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
if (session.stream) {
session.stream.write(input);
return { ok: true };
}
if (session.pty) {
session.pty.write(input);
return { ok: true };
}
return { ok: false, error: "No writable stream" };
}
// ── SFTP Helpers ──
function findSftpForSession(sessionId) {
// Try to find an SFTP client keyed by the same sessionId
if (sftpClients?.has(sessionId)) {
return sftpClients.get(sessionId);
}
// Look through all SFTP clients for one sharing the same SSH connection
const session = sessions?.get(sessionId);
if (!session?.sshClient) return null;
for (const [, client] of sftpClients || []) {
if (client.client === session.sshClient || client._sshClient === session.sshClient) {
return client;
}
}
return null;
}
// ── Handler: sftpList ──
async function handleSftpList(params) {
const { sessionId, path: dirPath } = params;
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
const list = await sftpClient.list(dirPath);
return {
files: list.map(f => ({
name: f.name,
type: f.type === "d" ? "directory" : f.type === "l" ? "symlink" : "file",
size: f.size,
lastModified: f.modifyTime,
permissions: f.rights ? `${f.rights.user}${f.rights.group}${f.rights.other}` : undefined,
})),
};
} catch (err) {
return { ok: false, error: err.message };
}
}
// Fallback: use SSH exec
const result = await handleExec({ sessionId, command: `ls -la ${shellQuote(dirPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { output: result.stdout || "(empty directory)" };
}
// ── Handler: sftpRead ──
async function handleSftpRead(params) {
const { sessionId, path: filePath } = params;
if (params.maxBytes != null && (typeof params.maxBytes !== 'number' || params.maxBytes < 1 || params.maxBytes > 10 * 1024 * 1024)) {
return { ok: false, error: 'maxBytes must be a positive number between 1 and 10485760' };
}
// Clamp maxBytes to a safe upper bound (10MB)
const maxBytes = Math.max(1, Math.min(Number(params.maxBytes) || 10000, 10 * 1024 * 1024));
if (!sessionId || !filePath) throw new Error("sessionId and path are required");
// Fallback to SSH exec (more reliable across SFTP client states)
const result = await handleExec({ sessionId, command: `head -c ${maxBytes} ${shellQuote(filePath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { content: result.stdout || "(empty file)" };
}
// ── Handler: sftpWrite ──
async function handleSftpWrite(params) {
const { sessionId, path: filePath, content } = params;
if (!sessionId || !filePath || content == null) throw new Error("sessionId, path and content are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.put(Buffer.from(content, "utf-8"), filePath);
return { written: filePath };
} catch {
// Fallback to SSH
}
}
// Use base64 encoding to avoid heredoc delimiter collision issues
const b64 = Buffer.from(content, "utf-8").toString("base64");
const result = await handleExec({ sessionId, command: `echo ${shellQuote(b64)} | base64 -d > ${shellQuote(filePath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { written: filePath };
}
// ── Handler: sftpMkdir ──
async function handleSftpMkdir(params) {
const { sessionId, path: dirPath } = params;
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.mkdir(dirPath, true); // recursive
return { created: dirPath };
} catch {
// Fallback
}
}
const result = await handleExec({ sessionId, command: `mkdir -p ${shellQuote(dirPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { created: dirPath };
}
// ── Handler: sftpRemove ──
// Critical paths that must never be removed (module-level constant)
const CRITICAL_PATHS = new Set([
"/", "/root", "/home", "/etc", "/var", "/usr", "/boot",
"/bin", "/sbin", "/lib", "/lib64", "/dev", "/proc", "/sys", "/tmp", "/opt",
]);
async function handleSftpRemove(params) {
const { sessionId, path: targetPath } = params;
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
// Guard against deleting root or critical system directories
// Normalize to resolve "..", "//", and trailing slashes before checking
const normalizedPath = path.posix.normalize(targetPath).replace(/\/+$/, "") || "/";
if (CRITICAL_PATHS.has(normalizedPath) || /^\/[^/]+$/.test(normalizedPath)) {
return { ok: false, error: `Refusing to remove critical or root-level path: ${targetPath}` };
}
// Use rm -r (without -f) so permission errors surface instead of being silently ignored
const result = await handleExec({ sessionId, command: `rm -r ${shellQuote(targetPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { removed: targetPath };
}
// ── Handler: sftpRename ──
async function handleSftpRename(params) {
const { sessionId, oldPath, newPath } = params;
if (!sessionId || !oldPath || !newPath) throw new Error("sessionId, oldPath and newPath are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
await sftpClient.rename(oldPath, newPath);
return { renamed: `${oldPath}${newPath}` };
} catch {
// Fallback
}
}
const result = await handleExec({ sessionId, command: `mv ${shellQuote(oldPath)} ${shellQuote(newPath)}` });
if (!result.ok) return { ok: false, error: result.error };
return { renamed: `${oldPath}${newPath}` };
}
// ── Handler: sftpStat ──
async function handleSftpStat(params) {
const { sessionId, path: targetPath } = params;
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
const sftpClient = findSftpForSession(sessionId);
if (sftpClient) {
try {
const stat = await sftpClient.stat(targetPath);
return {
name: path.basename(targetPath),
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
size: stat.size,
lastModified: stat.modifyTime,
permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined,
};
} catch {
// Fallback
}
}
// Fallback: use stat command
const result = await handleExec({ sessionId, command: `stat -c '{"size":%s,"mode":"%a","mtime":%Y,"type":"%F"}' ${shellQuote(targetPath)}` });
if (!result.ok) return { ok: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout.trim());
return {
name: path.basename(targetPath),
type: parsed.type?.includes("directory") ? "directory" : "file",
size: parsed.size,
lastModified: parsed.mtime * 1000,
permissions: parsed.mode,
};
} catch {
return { ok: false, error: "Failed to parse stat output" };
}
}
// ── Handler: multiExec ──
async function handleMultiExec(params) {
const { sessionIds, command, mode = "parallel", stopOnError = false } = params;
if (!Array.isArray(sessionIds) || !command) throw new Error("sessionIds and command are required");
if (sessionIds.length > 50) {
return { ok: false, error: 'Too many session IDs: maximum is 50' };
}
if (typeof command !== 'string' || !command.trim()) {
return { ok: false, error: 'Invalid command' };
}
const safety = checkCommandSafety(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
const results = {};
if (mode === "sequential") {
for (const sid of sessionIds) {
const result = await handleExec({ sessionId: sid, command });
results[sid] = {
ok: result.ok,
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
};
if (!result.ok && stopOnError) break;
}
} else {
// Parallel execution with concurrency limit
const tasks = sessionIds.map((sid) => () => {
return handleExec({ sessionId: sid, command }).then(result => ({
sid,
ok: result.ok,
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
}));
});
const resolved = await limitConcurrency(tasks, 10);
for (const r of resolved) {
results[r.sid] = { ok: r.ok, output: r.output };
}
}
return { results };
}
// ── MCP Server Config Builder ──
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
? scopedSessionIds
: getScopedSessionIds(chatSessionId);
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const env = [
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
if (authToken) {
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
}
if (effectiveIds && effectiveIds.length > 0) {
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
}
// Pass chatSessionId so MCP server can scope getContext responses
if (chatSessionId) {
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
}
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: "node",
args: [runtimePath],
env,
};
}
// ── Cleanup ──
function cleanupScopedMetadata(chatSessionId) {
if (chatSessionId) {
scopedMetadata.delete(chatSessionId);
}
}
function cleanup() {
if (tcpServer) {
tcpServer.close();
tcpServer = null;
tcpPort = null;
}
scopedMetadata.clear();
}
module.exports = {
init,
setCommandBlocklist,
setCommandTimeout,
getCommandTimeoutMs,
setMaxIterations,
getMaxIterations,
setPermissionMode,
getPermissionMode,
checkCommandSafety,
updateSessionMetadata,
getScopedSessionIds,
getOrCreateHost,
buildMcpServerConfig,
cancelAllPtyExecs,
cleanupScopedMetadata,
cleanup,
};

View File

@@ -124,7 +124,25 @@ async function findAllDefaultPrivateKeys(options = {}) {
}
/**
* Check if Windows SSH Agent service is running
* Check if a Windows named pipe exists (non-blocking).
* Works for OpenSSH Agent, Bitwarden SSH Agent, 1Password, etc.
*/
function windowsPipeExists(pipePath) {
try {
fs.statSync(pipePath);
return true;
} catch {
return false;
}
}
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if an SSH agent is available on Windows.
* Instead of checking the OpenSSH Authentication Agent *service*, we probe
* the well-known named pipe directly. This supports any agent that provides
* the pipe — Bitwarden, 1Password, gpg-agent, etc.
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
@@ -133,13 +151,7 @@ function checkWindowsSshAgentRunning() {
resolve(true);
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve(false);
return;
}
resolve(stdout.includes("RUNNING"));
});
resolve(windowsPipeExists(WIN_SSH_AGENT_PIPE));
});
}

View File

@@ -140,8 +140,12 @@ async function findAllDefaultPrivateKeys() {
return keys;
}
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if Windows SSH Agent service is running
* Check if an SSH agent is available on Windows by probing the well-known
* named pipe. This detects any agent that provides the pipe — OpenSSH Agent
* service, Bitwarden, 1Password, gpg-agent, etc.
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
*/
function checkWindowsSshAgent() {
@@ -150,18 +154,17 @@ function checkWindowsSshAgent() {
resolve({ running: true, startupType: null, error: null });
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
return;
}
const running = stdout.includes("RUNNING");
const stopped = stdout.includes("STOPPED");
resolve({
running,
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
error: null,
});
let pipeExists = false;
try {
fs.statSync(WIN_SSH_AGENT_PIPE);
pipeExists = true;
} catch {
// pipe not found
}
resolve({
running: pipeExists,
startupType: pipeExists ? "running" : "stopped",
error: pipeExists ? null : "SSH Agent pipe not found",
});
});
}
@@ -170,7 +173,7 @@ async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check", agentStatus);
return agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
return agentStatus.running ? WIN_SSH_AGENT_PIPE : null;
}
return getSshAgentSocket();
@@ -963,6 +966,10 @@ async function startSSHSession(event, options) {
stream,
chainConnections,
webContentsId: event.sender.id,
// Store connection info for MCP host discovery
hostname: options.host || options.hostname || '',
username: options.username || '',
label: options.label || '',
};
sessions.set(sessionId, session);

View File

@@ -808,13 +808,23 @@ async function createWindow(electronModule, options) {
closeSettingsWindow();
});
const safeSend = (channel, ...args) => {
try {
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch {
// Render frame disposed during HMR / reload safe to ignore
}
};
win.on("enter-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", true);
safeSend("netcatty:window:fullscreen-changed", true);
scheduleSaveState();
});
win.on("leave-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", false);
safeSend("netcatty:window:fullscreen-changed", false);
updateNormalBounds();
scheduleSaveState();
});
@@ -859,11 +869,14 @@ async function createWindow(electronModule, options) {
// Register window control handlers
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
// Register IPC handlers BEFORE loading any URL so the renderer never
// calls a handler that hasn't been registered yet.
onRegisterBridge?.(win);
if (isDev) {
try {
await win.loadURL(getDevRendererBaseUrl(devServerUrl));
win.webContents.openDevTools({ mode: "detach" });
onRegisterBridge?.(win);
return win;
} catch (e) {
console.warn("Dev server not reachable, falling back to bundled dist.", e);
@@ -872,8 +885,6 @@ async function createWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html");
onRegisterBridge?.(win);
return win;
}
@@ -977,12 +988,22 @@ async function openSettingsWindow(electronModule, options) {
}
}
const safeSend = (channel, ...args) => {
try {
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch {
// Render frame disposed during HMR / reload safe to ignore
}
};
win.on("enter-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", true);
safeSend("netcatty:window:fullscreen-changed", true);
});
win.on("leave-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", false);
safeSend("netcatty:window:fullscreen-changed", false);
});
// Ensure native background matches frontend background, even before first paint.

View File

@@ -6,3 +6,12 @@ delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(electronPath, ["."], { stdio: "inherit", env });
child.on("exit", (code) => process.exit(code ?? 0));
// Forward SIGINT/SIGTERM to the Electron child process so Ctrl+C works
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => {
if (!child.killed) {
child.kill(sig);
}
});
}

View File

@@ -83,6 +83,7 @@ const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
const aiBridge = require("./bridges/aiBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -378,7 +379,8 @@ const registerBridges = (win) => {
terminalBridge.init(deps);
fileWatcherBridge.init(deps);
globalShortcutBridge.init(deps);
aiBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
...deps,
@@ -408,6 +410,7 @@ const registerBridges = (win) => {
credentialBridge.registerHandlers(ipcMain, electronModule);
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
@@ -861,6 +864,11 @@ if (!gotLock) {
} catch (err) {
console.warn("Error during global shortcut cleanup:", err);
}
try {
aiBridge.cleanup();
} catch (err) {
console.warn("Error during AI bridge cleanup:", err);
}
});
}

View File

@@ -0,0 +1,431 @@
/**
* Netcatty MCP Server (stdio transport)
*
* Spawned by codex-acp (or other ACP agents) as a child process.
* Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON).
* Exposes SSH terminal and SFTP tools so ACP agents can operate on remote hosts.
*/
"use strict";
const net = require("node:net");
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { z } = require("zod");
// ── TCP Bridge to Netcatty main process ──
const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
if (!NETCATTY_MCP_PORT) {
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n");
process.exit(1);
}
// Auth token for TCP bridge authentication
const NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || "";
if (!NETCATTY_MCP_TOKEN) {
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_TOKEN not set\n");
process.exit(1);
}
// Scoped session IDs (comma-separated). When set (even if empty), only listed
// sessions are accessible. When unset, scope enforcement falls back to the
// TCP bridge's own scoping (which also defaults to no-access when empty).
const SCOPED_SESSION_IDS = process.env.NETCATTY_MCP_SESSION_IDS != null
? process.env.NETCATTY_MCP_SESSION_IDS.split(",").map(s => s.trim()).filter(Boolean)
: null;
// Chat session ID for per-scope metadata isolation
const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
// Permission mode: 'observer' | 'confirm' | 'autonomous' (defense-in-depth, TCP bridge also checks)
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
// Default command blocklist (defense-in-depth, TCP bridge also checks)
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
const DEFAULT_COMMAND_BLOCKLIST = [
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
'\\bmkfs\\.',
'\\bdd\\s+if=.*\\s+of=/dev/',
'\\b(shutdown|reboot|poweroff|halt)\\b',
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:',
'>\\s*/dev/sd',
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
'\\bmv\\s+/\\s',
':\\s*>\\s*/etc/',
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
];
// Pre-compile blocklist regexes once at module load time
const compiledBlocklist = DEFAULT_COMMAND_BLOCKLIST.map(pattern => {
try {
return new RegExp(pattern, "i");
} catch {
return null; // placeholder for invalid patterns
}
});
function checkCommandSafety(command) {
for (let i = 0; i < compiledBlocklist.length; i++) {
const re = compiledBlocklist[i];
if (re && re.test(command)) {
return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] };
}
}
return { blocked: false };
}
/** Guard for write tools: blocks in observer mode, checks command safety for commands. */
function guardWriteOperation(command) {
if (PERMISSION_MODE === "observer") {
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.';
}
if (command) {
const safety = checkCommandSafety(command);
if (safety.blocked) {
return `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
}
}
return null;
}
let tcpSocket = null;
let pendingRequests = new Map(); // id -> { resolve, reject }
let nextRpcId = 1;
let tcpBuffer = "";
function connectTcp() {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
tcpSocket = sock;
resolve();
});
sock.setEncoding("utf-8");
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
sock.on("data", (chunk) => {
tcpBuffer += chunk;
if (tcpBuffer.length > MAX_BUFFER_SIZE) {
process.stderr.write(`[netcatty-mcp] TCP buffer exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer\n`);
tcpBuffer = "";
return;
}
let newlineIdx;
while ((newlineIdx = tcpBuffer.indexOf("\n")) !== -1) {
const line = tcpBuffer.slice(0, newlineIdx);
tcpBuffer = tcpBuffer.slice(newlineIdx + 1);
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id != null && pendingRequests.has(msg.id)) {
const { resolve: res, reject: rej } = pendingRequests.get(msg.id);
pendingRequests.delete(msg.id);
if (msg.error) {
rej(new Error(msg.error.message || JSON.stringify(msg.error)));
} else {
res(msg.result);
}
}
} catch {
// ignore malformed lines
}
}
});
sock.on("error", (err) => {
reject(err);
// Reject all pending
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection lost"));
}
pendingRequests.clear();
});
sock.on("close", () => {
// Reject all pending requests on clean close
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection closed"));
}
pendingRequests.clear();
tcpSocket = null;
});
});
}
function rpcCall(method, params) {
return new Promise((resolve, reject) => {
if (!tcpSocket || tcpSocket.destroyed) {
return reject(new Error("Not connected to Netcatty"));
}
const id = nextRpcId++;
pendingRequests.set(id, { resolve, reject });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
tcpSocket.write(msg);
});
}
// ── MCP Server ──
const server = new McpServer({
name: "netcatty-remote-hosts",
version: "1.0.0",
});
// Scope params shared by all tool calls (includes chatSessionId for metadata isolation)
const scopeParams = { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
// Resource: environment context
server.resource(
"environment",
"netcatty://context",
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
async () => {
const ctx = await rpcCall("netcatty/getContext", scopeParams);
return {
contents: [{
uri: "netcatty://context",
mimeType: "application/json",
text: JSON.stringify(ctx, null, 2),
}],
};
},
);
// Tool: get_environment
server.tool(
"get_environment",
"Get information about the current Netcatty workspace: all connected remote hosts, their session IDs, OS, and connection status. Call this first to discover available hosts before executing commands.",
{},
async () => {
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}\n`);
const ctx = await rpcCall("netcatty/getContext", scopeParams);
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
},
);
// Tool: terminal_execute
server.tool(
"terminal_execute",
"Execute a shell command on a remote host via SSH. The command runs in the host's shell and output (stdout/stderr) is returned when complete.",
{
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
command: z.string().describe("The shell command to execute on the remote host."),
},
async ({ sessionId, command }) => {
const guardErr = guardWriteOperation(command);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/exec", { sessionId, command });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
}
const parts = [];
if (result.stdout) parts.push(result.stdout);
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
parts.push(`[exit code: ${result.exitCode ?? -1}]`);
return { content: [{ type: "text", text: parts.join("\n") }] };
},
);
// Tool: terminal_send_input
server.tool(
"terminal_send_input",
"Send raw input to a terminal session on a remote host. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
{
sessionId: z.string().describe("The terminal session ID to send input to."),
input: z.string().describe("The raw input string. Use escape sequences for special keys (e.g. \\x03 for ctrl+c, \\n for enter)."),
},
async ({ sessionId, input }) => {
const guardErr = guardWriteOperation(input);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/terminalWrite", { sessionId, input });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Sent input to session ${sessionId}` }] };
},
);
// Tool: sftp_list_directory
server.tool(
"sftp_list_directory",
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote directory to list."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpList", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
},
);
// Tool: sftp_read_file
server.tool(
"sftp_read_file",
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to read."),
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
},
async ({ sessionId, path, maxBytes }) => {
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
const result = await rpcCall("netcatty/sftpRead", { sessionId, path, maxBytes: safeMaxBytes });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
},
);
// Tool: sftp_write_file
server.tool(
"sftp_write_file",
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to write."),
content: z.string().describe("The text content to write to the file."),
},
async ({ sessionId, path, content }) => {
const guardErr = guardWriteOperation(path);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpWrite", { sessionId, path, content });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Written: ${path}` }] };
},
);
// Tool: sftp_mkdir
server.tool(
"sftp_mkdir",
"Create a directory on the remote host. Creates parent directories if they don't exist.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the directory to create."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpMkdir", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
},
);
// Tool: sftp_remove
server.tool(
"sftp_remove",
"Delete a file or directory on the remote host. Directories are removed recursively.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the file or directory to delete."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRemove", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Removed: ${path}` }] };
},
);
// Tool: sftp_rename
server.tool(
"sftp_rename",
"Rename or move a file/directory on the remote host.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
oldPath: z.string().describe("The current absolute path."),
newPath: z.string().describe("The new absolute path."),
},
async ({ sessionId, oldPath, newPath }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRename", { sessionId, oldPath, newPath });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Renamed: ${oldPath}${newPath}` }] };
},
);
// Tool: sftp_stat
server.tool(
"sftp_stat",
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path to stat."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpStat", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
// Tool: multi_host_execute
server.tool(
"multi_host_execute",
"Execute a command on multiple remote hosts simultaneously or sequentially. Useful for fleet-wide operations like checking status, deploying updates, or maintenance.",
{
sessionIds: z.array(z.string()).describe("Array of session IDs to execute on."),
command: z.string().describe("The shell command to execute on each host."),
mode: z.enum(["parallel", "sequential"]).optional().default("parallel").describe("Execution mode. Defaults to parallel."),
stopOnError: z.boolean().optional().default(false).describe("In sequential mode, stop on first failure."),
},
async ({ sessionIds, command, mode, stopOnError }) => {
const guardErr = guardWriteOperation(command);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/multiExec", { sessionIds, command, mode, stopOnError });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result.results, null, 2) }] };
},
);
// ── Start ──
async function main() {
await connectTcp();
// Authenticate with the TCP bridge before accepting any tool calls
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
if (!authResult?.ok) {
throw new Error("TCP bridge authentication failed");
}
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`[netcatty-mcp] Fatal: ${err.message}\n`);
process.exit(1);
});

View File

@@ -27,12 +27,33 @@ function cleanupTransferListeners(transferId) {
transferCancelledListeners.delete(transferId);
}
// Filter MCP marker artifacts from terminal output:
// 1. Marker output lines (standalone): __NCMCP_xxx_S or __NCMCP_xxx_E:0
// 2. End marker command echo: __nc=$?;printf '__NCMCP_...'
// 3. Start marker printf prefix in echoed command: printf '__NCMCP_...\n';
// We keep the actual command part visible.
function filterMcpMarkers(data) {
return data
// Remove standalone marker output lines (printf output)
.replace(/^__NCMCP_[^\r\n]*[\r\n]*/gm, "")
// Remove end marker command echo lines
.replace(/[^\r\n]*__nc=\$\?;printf '[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/g, "")
// Remove start marker printf prefix from combined command lines
.replace(/printf '__NCMCP_[^']*\\n';/g, "");
}
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
// Filter MCP marker artifacts before they reach xterm.js
let data = payload.data;
if (data.includes("__NCMCP_")) {
data = filterMcpMarkers(data);
if (!data) return;
}
set.forEach((cb) => {
try {
cb(payload.data);
cb(data);
} catch (err) {
console.error("Data callback failed", err);
}
@@ -946,6 +967,8 @@ const api = {
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
setAutoUpdate: (enabled) => ipcRenderer.invoke("netcatty:update:setAutoUpdate", { enabled }),
getAutoUpdate: () => ipcRenderer.invoke("netcatty:update:getAutoUpdate"),
onUpdateAvailable: (cb) => {
updateAvailableListeners.add(cb);
return () => updateAvailableListeners.delete(cb);
@@ -966,6 +989,148 @@ const api = {
updateErrorListeners.add(cb);
return () => updateErrorListeners.delete(cb);
},
// ── AI Bridge ──
aiSyncProviders: async (providers) => {
return ipcRenderer.invoke("netcatty:ai:sync-providers", { providers });
},
aiChatStream: async (requestId, url, headers, body, providerId) => {
return ipcRenderer.invoke("netcatty:ai:chat:stream", { requestId, url, headers, body, providerId });
},
aiChatCancel: async (requestId) => {
return ipcRenderer.invoke("netcatty:ai:chat:cancel", { requestId });
},
aiFetch: async (url, method, headers, body, providerId) => {
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId });
},
aiExec: async (sessionId, command) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
},
aiTerminalWrite: async (sessionId, data) => {
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
},
aiDiscoverAgents: async () => {
return ipcRenderer.invoke("netcatty:ai:agents:discover");
},
aiResolveCli: async (params) => {
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
},
aiCodexGetIntegration: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
},
aiCodexStartLogin: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
},
aiCodexGetLoginSession: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ai:codex:get-login-session", { sessionId });
},
aiCodexCancelLogin: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ai:codex:cancel-login", { sessionId });
},
aiCodexLogout: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:logout");
},
aiSpawnAgent: async (agentId, command, args, env, options) => {
return ipcRenderer.invoke("netcatty:ai:agent:spawn", { agentId, command, args, env, closeStdin: options?.closeStdin });
},
aiWriteToAgent: async (agentId, data) => {
return ipcRenderer.invoke("netcatty:ai:agent:write", { agentId, data });
},
aiCloseAgentStdin: async (agentId) => {
return ipcRenderer.invoke("netcatty:ai:agent:close-stdin", { agentId });
},
aiKillAgent: async (agentId) => {
return ipcRenderer.invoke("netcatty:ai:agent:kill", { agentId });
},
// MCP Server session metadata
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
},
aiMcpSetCommandBlocklist: async (blocklist) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-blocklist", { blocklist });
},
aiMcpSetCommandTimeout: async (timeout) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-timeout", { timeout });
},
aiMcpSetMaxIterations: async (maxIterations) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-max-iterations", { maxIterations });
},
aiMcpSetPermissionMode: async (mode) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images });
},
aiAcpCancel: async (requestId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId });
},
aiAcpCleanup: async (chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
},
onAiAcpEvent: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb(payload.event);
};
ipcRenderer.on("netcatty:ai:acp:event", handler);
return () => ipcRenderer.removeListener("netcatty:ai:acp:event", handler);
},
onAiAcpDone: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb();
};
ipcRenderer.on("netcatty:ai:acp:done", handler);
return () => ipcRenderer.removeListener("netcatty:ai:acp:done", handler);
},
onAiAcpError: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb(payload.error);
};
ipcRenderer.on("netcatty:ai:acp:error", handler);
return () => ipcRenderer.removeListener("netcatty:ai:acp:error", handler);
},
onAiStreamData: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb(payload.data);
};
ipcRenderer.on("netcatty:ai:stream:data", handler);
return () => ipcRenderer.removeListener("netcatty:ai:stream:data", handler);
},
onAiStreamEnd: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb();
};
ipcRenderer.on("netcatty:ai:stream:end", handler);
return () => ipcRenderer.removeListener("netcatty:ai:stream:end", handler);
},
onAiStreamError: (requestId, cb) => {
const handler = (_event, payload) => {
if (payload.requestId === requestId) cb(payload.error);
};
ipcRenderer.on("netcatty:ai:stream:error", handler);
return () => ipcRenderer.removeListener("netcatty:ai:stream:error", handler);
},
onAiAgentStdout: (agentId, cb) => {
const handler = (_event, payload) => {
if (payload.agentId === agentId) cb(payload.data);
};
ipcRenderer.on("netcatty:ai:agent:stdout", handler);
return () => ipcRenderer.removeListener("netcatty:ai:agent:stdout", handler);
},
onAiAgentStderr: (agentId, cb) => {
const handler = (_event, payload) => {
if (payload.agentId === agentId) cb(payload.data);
};
ipcRenderer.on("netcatty:ai:agent:stderr", handler);
return () => ipcRenderer.removeListener("netcatty:ai:agent:stderr", handler);
},
onAiAgentExit: (agentId, cb) => {
const handler = (_event, payload) => {
if (payload.agentId === agentId) cb(payload.code);
};
ipcRenderer.on("netcatty:ai:agent:exit", handler);
return () => ipcRenderer.removeListener("netcatty:ai:agent:exit", handler);
},
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
},
{
files: ["**/*.{ts,tsx}"],

94
global.d.ts vendored
View File

@@ -612,10 +612,98 @@ declare global {
credentialsEncrypt?(plaintext: string): Promise<string>;
credentialsDecrypt?(value: string): Promise<string>;
// AI / external agents
aiSyncProviders?(providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>): Promise<{ ok: boolean }>;
aiChatStream?(requestId: string, url: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
aiChatCancel?(requestId: string): Promise<boolean>;
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiDiscoverAgents?(): Promise<Array<{
command: string;
name: string;
icon: string;
description: string;
args: string[];
path: string;
version: string;
available: boolean;
acpCommand?: string;
acpArgs?: string[];
}>>;
aiCodexGetIntegration?(): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
}>;
aiCodexStartLogin?(): Promise<{
ok: boolean;
session?: {
sessionId: string;
state: 'running' | 'success' | 'error' | 'cancelled';
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
};
error?: string;
}>;
aiCodexGetLoginSession?(sessionId: string): Promise<{
ok: boolean;
session?: {
sessionId: string;
state: 'running' | 'success' | 'error' | 'cancelled';
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
};
error?: string;
}>;
aiCodexCancelLogin?(sessionId: string): Promise<{
ok: boolean;
found?: boolean;
session?: {
sessionId: string;
state: 'running' | 'success' | 'error' | 'cancelled';
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
};
error?: string;
}>;
aiCodexLogout?(): Promise<{
ok: boolean;
state?: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
isConnected?: boolean;
rawOutput?: string;
logoutOutput?: string;
error?: string;
}>;
aiMcpUpdateSessions?(sessions: Array<{ sessionId: string; hostname: string; label: string; os?: string; username?: string; connected: boolean }>): Promise<{ ok: boolean }>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
onAiAcpDone?(requestId: string, cb: () => void): () => void;
onAiAcpError?(requestId: string, cb: (error: string) => void): () => void;
onAiStreamData?(requestId: string, cb: (data: string) => void): () => void;
onAiStreamEnd?(requestId: string, cb: () => void): () => void;
onAiAgentStdout?(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr?(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit?(agentId: string, cb: (code: number | null) => void): () => void;
// Auto-update
checkForUpdate?(): Promise<{
available: boolean;
supported?: boolean;
checking?: boolean;
version?: string;
releaseNotes?: string;
releaseDate?: string | null;
@@ -623,7 +711,7 @@ declare global {
}>;
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
installUpdate?(): void;
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
getUpdateStatus?(): Promise<{ status: 'idle' | 'available' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
onUpdateDownloadProgress?(cb: (progress: {
percent: number;
@@ -645,6 +733,10 @@ declare global {
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
// Auto-Update toggle
getAutoUpdate?(): Promise<{ enabled: boolean }>;
setAutoUpdate?(enabled: boolean): Promise<{ success: boolean }>;
// System Tray / Close to Tray
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
isCloseToTray?(): Promise<{ enabled: boolean }>;

View File

@@ -128,6 +128,31 @@ body {
}
}
.thinking-shimmer {
background: linear-gradient(
90deg,
currentColor 0%,
currentColor 40%,
rgba(255, 255, 255, 0.6) 50%,
currentColor 60%,
currentColor 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: thinking-shimmer 1.5s ease-in-out infinite;
}
@keyframes thinking-shimmer {
0% {
background-position: 100% center;
}
100% {
background-position: -100% center;
}
}
@keyframes progress-shimmer {
0% {
transform: translateX(-200%);
@@ -282,3 +307,71 @@ body {
.workspace-pane:focus-within::after {
opacity: 1;
}
/* ── Streamdown code block overrides ── */
[data-streamdown="code-block"] {
position: relative !important;
border-radius: 10px !important;
background: hsl(var(--muted) / 0.5) !important;
overflow: hidden !important;
margin: 6px 0 !important;
padding: 0 !important;
border: none !important;
gap: 0 !important;
}
[data-streamdown="code-block-header"] {
height: auto !important;
padding: 4px 12px 0 !important;
font-size: 11px !important;
}
[data-streamdown="code-block-header"] span {
margin-left: 0 !important;
}
[data-streamdown="code-block-actions"] {
position: absolute !important;
top: 4px !important;
right: 4px !important;
border: none !important;
background: none !important;
backdrop-filter: none !important;
padding: 0 !important;
gap: 2px !important;
opacity: 0;
transition: opacity 0.15s;
}
[data-streamdown="code-block"]:hover [data-streamdown="code-block-actions"] {
opacity: 1;
}
[data-streamdown="code-block-actions"] button {
padding: 4px !important;
border-radius: 4px;
}
[data-streamdown="code-block-body"] {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
overflow-x: auto !important;
font-size: 0 !important; /* collapse whitespace text nodes */
}
[data-streamdown="code-block-body"] pre {
font-size: 12px !important; /* restore in pre */
}
[data-streamdown="code-block"] pre {
margin: 0 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 12px 10px !important;
font-size: 12px !important;
line-height: 1.5 !important;
}

View File

@@ -0,0 +1,398 @@
import type {
JsonRpcRequest,
JsonRpcResponse,
JsonRpcNotification,
JsonRpcMessage,
InitializeParams,
InitializeResult,
SessionCreateParams,
PromptParams,
SessionUpdateParams,
PermissionRequestParams,
AgentCapabilities,
} from './protocol';
import { ACP_METHODS } from './protocol';
import type { ExternalAgentConfig } from '../types';
type EventHandler<T = unknown> = (params: T) => void;
// ── Lightweight runtime type guards ──
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isPermissionRequestParams(v: unknown): v is PermissionRequestParams {
if (!isRecord(v)) return false;
if (typeof v.sessionId !== 'string') return false;
if (!isRecord(v.toolCall)) return false;
if (typeof v.toolCall.name !== 'string') return false;
return true;
}
function isSessionUpdateParams(v: unknown): v is SessionUpdateParams {
if (!isRecord(v)) return false;
if (typeof v.sessionId !== 'string') return false;
if (typeof v.type !== 'string') return false;
return true;
}
function isJsonRpcError(v: unknown): v is { code: number; message: string } {
if (!isRecord(v)) return false;
if (typeof v.code !== 'number') return false;
if (typeof v.message !== 'string') return false;
return true;
}
/**
* Bridge interface to the Electron main process for agent management
*/
interface AgentBridge {
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
/**
* ACP Client - manages a single external agent connection over JSON-RPC 2.0 / NDJSON stdio.
*/
export class ACPClient {
private agentId: string;
private config: ExternalAgentConfig;
private bridge: AgentBridge;
private nextId = 1;
private pendingRequests = new Map<number | string, {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}>();
private buffer = '';
private cleanupFns: (() => void)[] = [];
private agentCapabilities: AgentCapabilities | null = null;
private _isConnected = false;
// Event handlers
private onSessionUpdate: EventHandler<SessionUpdateParams> | null = null;
private onPermissionRequest: EventHandler<PermissionRequestParams> | null = null;
private onStderr: EventHandler<string> | null = null;
private onExit: EventHandler<number> | null = null;
constructor(config: ExternalAgentConfig, bridge: AgentBridge) {
this.agentId = `acp_${config.id}_${Date.now()}`;
this.config = config;
this.bridge = bridge;
}
get isConnected() { return this._isConnected; }
get capabilities() { return this.agentCapabilities; }
/** Set event handlers */
on(event: 'session_update', handler: EventHandler<SessionUpdateParams>): this;
on(event: 'permission_request', handler: EventHandler<PermissionRequestParams>): this;
on(event: 'stderr', handler: EventHandler<string>): this;
on(event: 'exit', handler: EventHandler<number>): this;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, handler: EventHandler<any>): this {
switch (event) {
case 'session_update': this.onSessionUpdate = handler as EventHandler<SessionUpdateParams>; break;
case 'permission_request': this.onPermissionRequest = handler as EventHandler<PermissionRequestParams>; break;
case 'stderr': this.onStderr = handler as EventHandler<string>; break;
case 'exit': this.onExit = handler as EventHandler<number>; break;
}
return this;
}
/** Start the agent process and perform ACP initialization handshake */
async connect(): Promise<InitializeResult> {
// Spawn the agent process
const result = await this.bridge.aiSpawnAgent(
this.agentId,
this.config.command,
this.config.args,
this.config.env,
);
if (!result.ok) {
throw new Error(`Failed to spawn agent: ${result.error}`);
}
// Listen for stdout (NDJSON messages)
const unsubStdout = this.bridge.onAiAgentStdout(this.agentId, (data) => {
this.handleStdoutData(data);
});
this.cleanupFns.push(unsubStdout);
// Listen for stderr (logging)
const unsubStderr = this.bridge.onAiAgentStderr(this.agentId, (data) => {
this.onStderr?.(data);
});
this.cleanupFns.push(unsubStderr);
// Listen for exit
const unsubExit = this.bridge.onAiAgentExit(this.agentId, (code) => {
this._isConnected = false;
this.onExit?.(code);
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error(`Agent exited with code ${code}`));
}
this.pendingRequests.clear();
});
this.cleanupFns.push(unsubExit);
// Send initialize request
const initParams: InitializeParams = {
clientInfo: { name: 'netcatty', version: '1.0.0' },
capabilities: {
terminal: { create: true, output: true, waitForExit: true, kill: true },
fileSystem: { read: true, write: true },
permissions: { requestPermission: true },
},
};
const initResult = await this.sendRequest<InitializeResult>(ACP_METHODS.INITIALIZE, initParams);
this.agentCapabilities = initResult.capabilities;
this._isConnected = true;
return initResult;
}
/** Create a new session */
async createSession(params?: SessionCreateParams): Promise<{ sessionId: string }> {
return this.sendRequest(ACP_METHODS.SESSION_CREATE, params || {});
}
/** Send a prompt to the agent */
async prompt(params: PromptParams): Promise<void> {
return this.sendRequest(ACP_METHODS.SESSION_PROMPT, params);
}
/** Cancel the current operation */
async cancel(sessionId: string): Promise<void> {
return this.sendRequest(ACP_METHODS.SESSION_CANCEL, { sessionId });
}
/** Respond to a permission request */
respondPermission(requestId: number | string, approved: boolean): void {
this.sendResponse(requestId, { approved });
}
/** Respond to a terminal create request */
respondTerminalCreate(requestId: number | string, terminalId: string): void {
this.sendResponse(requestId, { terminalId });
}
/** Respond to a file read request */
respondFileRead(requestId: number | string, content: string): void {
this.sendResponse(requestId, { content });
}
/** Respond to a file write request */
respondFileWrite(requestId: number | string, success: boolean): void {
this.sendResponse(requestId, { success });
}
/** Disconnect and kill the agent process */
async disconnect(): Promise<void> {
this._isConnected = false;
for (const cleanup of this.cleanupFns) {
try { cleanup(); } catch { /* ignore cleanup errors */ }
}
this.cleanupFns = [];
await this.bridge.aiKillAgent(this.agentId);
// Reject all pending requests before clearing
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error('Agent disconnected'));
}
this.pendingRequests.clear();
}
// ── Private methods ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async sendRequest<T = unknown>(method: string, params?: Record<string, any>): Promise<T> {
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise<T>((resolve, reject) => {
// Track timeout so we can clear it when the request resolves
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}
}, 30000);
this.pendingRequests.set(id, {
resolve: (result: unknown) => {
clearTimeout(timeoutId);
(resolve as (result: unknown) => void)(result);
},
reject: (error: Error) => {
clearTimeout(timeoutId);
reject(error);
},
});
const line = JSON.stringify(request) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
clearTimeout(timeoutId);
this.pendingRequests.delete(id);
reject(err);
});
});
}
private sendResponse(id: number | string, result: unknown): void {
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
const line = JSON.stringify(response) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
console.error('[ACP] Failed to send response:', err);
});
}
private sendErrorResponse(id: number | string, code: number, message: string): void {
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id,
error: { code, message },
};
const line = JSON.stringify(response) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch(() => { /* best-effort */ });
}
/** Max NDJSON buffer size (10 MB) to prevent unbounded memory growth */
private static readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024;
private handleStdoutData(data: string): void {
this.buffer += data;
// Guard against unbounded buffer growth
if (this.buffer.length > ACPClient.MAX_BUFFER_SIZE) {
console.warn(`[ACP] NDJSON buffer exceeded ${ACPClient.MAX_BUFFER_SIZE} bytes, clearing buffer`);
this.buffer = '';
return;
}
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed) as JsonRpcMessage;
this.handleMessage(message);
} catch {
// Skip non-JSON lines (agent may print logs to stdout)
}
}
}
private handleMessage(message: JsonRpcMessage): void {
// Response to our request
if ('id' in message && ('result' in message || 'error' in message)) {
const response = message as JsonRpcResponse;
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.error) {
const errMsg = isJsonRpcError(response.error)
? response.error.message
: JSON.stringify(response.error);
pending.reject(new Error(errMsg));
} else {
pending.resolve(response.result);
}
}
return;
}
// Request from agent (needs our response)
if ('id' in message && 'method' in message) {
const request = message as JsonRpcRequest;
this.handleAgentRequest(request);
return;
}
// Notification from agent (no response needed)
if ('method' in message && !('id' in message)) {
const notification = message as JsonRpcNotification;
this.handleAgentNotification(notification);
}
}
private handleAgentRequest(request: JsonRpcRequest): void {
switch (request.method) {
case ACP_METHODS.REQUEST_PERMISSION: {
if (!isPermissionRequestParams(request.params)) {
this.sendErrorResponse(request.id, -32602, 'Invalid permission request params');
break;
}
if (this.onPermissionRequest) {
this.onPermissionRequest({
...request.params,
// Attach the request ID so the handler can respond via respondPermission()
_requestId: request.id,
} as PermissionRequestParams & { _requestId: number | string });
} else {
this.sendErrorResponse(request.id, -32603, 'Permission request handler not configured');
}
break;
}
case ACP_METHODS.TERMINAL_CREATE:
case ACP_METHODS.TERMINAL_WAIT_EXIT:
case ACP_METHODS.TERMINAL_KILL:
case ACP_METHODS.FS_READ:
case ACP_METHODS.FS_WRITE:
// Surface as tool_call so the UI layer can handle and respond
this.onSessionUpdate?.({
sessionId: String(request.params?.sessionId || ''),
type: 'tool_call',
toolCall: {
id: String(request.id),
name: request.method,
arguments: (request.params as Record<string, unknown>) || {},
},
});
break;
default:
// Unknown method - respond with JSON-RPC method-not-found error
this.sendErrorResponse(request.id, -32601, `Method not found: ${request.method}`);
}
}
private handleAgentNotification(notification: JsonRpcNotification): void {
switch (notification.method) {
case ACP_METHODS.SESSION_UPDATE:
if (isSessionUpdateParams(notification.params)) {
this.onSessionUpdate?.(notification.params);
}
break;
case ACP_METHODS.TERMINAL_OUTPUT:
// Surface terminal output as a session update with tool_result type
this.onSessionUpdate?.({
sessionId: String(notification.params?.sessionId || ''),
type: 'tool_result',
toolResult: {
toolCallId: String(notification.params?.terminalId || ''),
content: String(notification.params?.data || ''),
},
});
break;
default:
// Ignore unknown notifications
break;
}
}
}

View File

@@ -0,0 +1,93 @@
import { ACPClient } from './client';
import type { ExternalAgentConfig } from '../types';
import type { SessionUpdateParams, PermissionRequestParams, InitializeResult } from './protocol';
interface AgentBridge {
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
export interface ACPManagerCallbacks {
onSessionUpdate: (agentConfigId: string, params: SessionUpdateParams) => void;
onPermissionRequest: (agentConfigId: string, params: PermissionRequestParams) => void;
onAgentError: (agentConfigId: string, error: string) => void;
onAgentExit: (agentConfigId: string, code: number) => void;
}
/**
* Manages multiple ACP agent connections.
*/
export class ACPManager {
private clients = new Map<string, ACPClient>();
private bridge: AgentBridge;
private callbacks: ACPManagerCallbacks;
constructor(bridge: AgentBridge, callbacks: ACPManagerCallbacks) {
this.bridge = bridge;
this.callbacks = callbacks;
}
/** Connect to an external agent */
async connect(config: ExternalAgentConfig): Promise<InitializeResult> {
if (this.clients.has(config.id)) {
await this.disconnect(config.id);
}
const client = new ACPClient(config, this.bridge);
client
.on('session_update', (params) => {
this.callbacks.onSessionUpdate(config.id, params);
})
.on('permission_request', (params) => {
this.callbacks.onPermissionRequest(config.id, params);
})
.on('stderr', (data) => {
this.callbacks.onAgentError(config.id, data);
})
.on('exit', (code) => {
this.clients.delete(config.id);
this.callbacks.onAgentExit(config.id, code);
});
const result = await client.connect();
this.clients.set(config.id, client);
return result;
}
/** Get a connected client */
getClient(configId: string): ACPClient | undefined {
return this.clients.get(configId);
}
/** Check if an agent is connected */
isConnected(configId: string): boolean {
return this.clients.get(configId)?.isConnected ?? false;
}
/** Disconnect a specific agent */
async disconnect(configId: string): Promise<void> {
const client = this.clients.get(configId);
if (client) {
await client.disconnect();
this.clients.delete(configId);
}
}
/** Disconnect all agents */
async disconnectAll(): Promise<void> {
const promises = Array.from(this.clients.keys()).map(id => this.disconnect(id));
await Promise.allSettled(promises);
}
/** Get list of connected agent IDs */
getConnectedAgentIds(): string[] {
return Array.from(this.clients.entries())
.filter(([, client]) => client.isConnected)
.map(([id]) => id);
}
}

View File

@@ -0,0 +1,94 @@
// JSON-RPC 2.0 base types
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: number | string;
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: number | string;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
// ACP-specific types
/** Capabilities that the client (Netcatty) declares it supports */
export interface ClientCapabilities {
fileSystem?: { read?: boolean; write?: boolean };
terminal?: { create?: boolean; output?: boolean; waitForExit?: boolean; kill?: boolean };
permissions?: { requestPermission?: boolean };
}
/** Capabilities that the agent declares it supports */
export interface AgentCapabilities {
streaming?: boolean;
tools?: string[];
}
/** ACP initialize params */
export interface InitializeParams {
clientInfo: { name: string; version: string };
capabilities: ClientCapabilities;
}
/** ACP initialize result */
export interface InitializeResult {
agentInfo: { name: string; version: string };
capabilities: AgentCapabilities;
}
/** ACP session create params */
export interface SessionCreateParams {
sessionId?: string;
context?: Record<string, unknown>;
}
/** ACP prompt params - send a user message */
export interface PromptParams {
sessionId: string;
message: string;
context?: Record<string, unknown>;
}
/** ACP session update events (streamed as notifications) */
export interface SessionUpdateParams {
sessionId: string;
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done';
content?: string;
toolCall?: { id: string; name: string; arguments: Record<string, unknown> };
toolResult?: { toolCallId: string; content: string; isError?: boolean };
}
/** ACP permission request */
export interface PermissionRequestParams {
sessionId: string;
toolCall: { name: string; arguments: Record<string, unknown> };
description?: string;
}
// ACP method names
export const ACP_METHODS = {
INITIALIZE: 'initialize',
SESSION_CREATE: 'session/create',
SESSION_PROMPT: 'session/prompt',
SESSION_CANCEL: 'session/cancel',
SESSION_UPDATE: 'session/update', // notification from agent
REQUEST_PERMISSION: 'session/request_permission', // request from agent
TERMINAL_CREATE: 'terminal/create', // request from agent
TERMINAL_OUTPUT: 'terminal/output', // notification from agent
TERMINAL_WAIT_EXIT: 'terminal/waitForExit', // request from agent
TERMINAL_KILL: 'terminal/kill', // request from agent
FS_READ: 'fs/readTextFile', // request from agent
FS_WRITE: 'fs/writeTextFile', // request from agent
} as const;

View File

@@ -0,0 +1,186 @@
/**
* ACP Agent Adapter
*
* Bridges external agents that support the Agent Client Protocol (ACP)
* through IPC. The main process runs `createACPProvider` + `streamText`,
* and forwards stream events to the renderer via IPC.
*/
import type { ExternalAgentConfig } from './types';
export interface AcpAgentCallbacks {
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
onThinkingDone: () => void;
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
onToolResult: (toolCallId: string, result: string) => void;
onStatus?: (message: string) => void;
onError: (error: string) => void;
onDone: () => void;
}
interface AcpBridge {
aiAcpStream(
requestId: string,
chatSessionId: string,
acpCommand: string,
acpArgs: string[],
prompt: string,
cwd?: string,
providerId?: string,
model?: string,
images?: ImageAttachment[],
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiAcpDone(requestId: string, cb: () => void): () => void;
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
}
interface StreamEvent {
type: string;
[key: string]: unknown;
}
/**
* Run an ACP agent turn.
* Sends the prompt to the main process which runs streamText() with the ACP provider.
* Stream events are forwarded back via IPC.
*/
export interface ImageAttachment {
base64Data: string;
mediaType: string;
filename?: string;
}
export async function runAcpAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
chatSessionId: string,
config: ExternalAgentConfig,
prompt: string,
callbacks: AcpAgentCallbacks,
signal?: AbortSignal,
providerId?: string,
model?: string,
images?: ImageAttachment[],
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
if (!config.acpCommand) {
callbacks.onError('Agent does not support ACP protocol');
return;
}
const cleanupFns: (() => void)[] = [];
// Set up event listeners before starting stream
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
handleStreamEvent(event, callbacks);
});
cleanupFns.push(unsubEvent);
const donePromise = new Promise<void>((resolve) => {
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
callbacks.onDone();
resolve();
});
cleanupFns.push(unsubDone);
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
callbacks.onError(error);
resolve();
});
cleanupFns.push(unsubError);
});
// Handle abort
if (signal) {
if (signal.aborted) {
cleanup(cleanupFns);
return;
}
const onAbort = () => {
acpBridge.aiAcpCancel(requestId).catch(() => {});
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
}
// Start the ACP stream in the main process
acpBridge.aiAcpStream(
requestId,
chatSessionId,
config.acpCommand,
config.acpArgs || [],
prompt,
undefined, // cwd
providerId,
model,
images?.length ? images : undefined,
).catch((err: Error) => {
callbacks.onError(err.message);
});
// Wait for done or error
await donePromise;
cleanup(cleanupFns);
}
function cleanup(fns: (() => void)[]) {
for (const fn of fns) {
try { fn(); } catch { /* */ }
}
}
/**
* Handle a single stream event from the AI SDK fullStream.
* Events come from `streamText().fullStream` in the main process.
*/
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
switch (event.type) {
case 'text-delta': {
const text = (event.textDelta as string) || (event.delta as string) || '';
if (text) callbacks.onTextDelta(text);
break;
}
case 'reasoning-start': {
// Reasoning block started — nothing to render yet
break;
}
case 'reasoning-delta': {
const text = (event.delta as string) || '';
if (text) callbacks.onThinkingDelta(text);
break;
}
case 'reasoning-end': {
callbacks.onThinkingDone();
break;
}
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
const input = (event.input as Record<string, unknown>) || {};
callbacks.onToolCall(toolName, input);
break;
}
case 'tool-result': {
const toolCallId = (event.toolCallId as string) || '';
const output = event.output ?? event.result;
const result = typeof output === 'string'
? output
: JSON.stringify(output);
callbacks.onToolResult(toolCallId, result);
break;
}
case 'status': {
const msg = (event.message as string) || '';
if (msg) callbacks.onStatus?.(msg);
break;
}
case 'error': {
callbacks.onError(String(event.error || 'Unknown error'));
break;
}
// step-start, step-finish, etc. — ignore silently
}
}

View File

@@ -0,0 +1,183 @@
/**
* Agent Output Parser
*
* Parses JSON Lines output from `codex exec --json` and similar structured
* agent output into display-friendly text segments.
*/
export interface AgentOutputSegment {
type: 'thinking' | 'text' | 'command' | 'command_output' | 'file_change' | 'plan' | 'error' | 'usage';
content: string;
}
/**
* Try to parse a single line of agent output.
* Returns structured segment(s) if it's a recognized JSON event,
* or null if it's not JSON / not recognized (caller should treat as plain text).
*/
export function parseAgentJsonLine(line: string): AgentOutputSegment[] | null {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('{')) return null;
let event: Record<string, unknown>;
try {
event = JSON.parse(trimmed);
} catch {
return null;
}
if (!event.type) return null;
const type = event.type as string;
const item = event.item as Record<string, unknown> | undefined;
// thread.started / turn.started — skip silently
if (type === 'thread.started' || type === 'turn.started') {
return [];
}
// turn.completed — show token usage
if (type === 'turn.completed') {
const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
if (usage) {
return [{
type: 'usage',
content: `tokens: ${usage.input_tokens ?? '?'} in / ${usage.output_tokens ?? '?'} out`,
}];
}
return [];
}
// error
if (type === 'error' || type === 'turn.failed') {
const msg = (event.message as string)
|| ((event.error as Record<string, unknown>)?.message as string)
|| JSON.stringify(event);
return [{ type: 'error', content: msg }];
}
// item events
if (type.startsWith('item.') && item) {
return parseItemEvent(type, item);
}
return null;
}
function parseItemEvent(
eventType: string,
item: Record<string, unknown>,
): AgentOutputSegment[] {
const itemType = item.type as string;
// reasoning (thinking)
if (itemType === 'reasoning') {
if (eventType !== 'item.completed') return [];
const text = item.text as string || '';
if (!text.trim()) return [];
return [{ type: 'thinking', content: text }];
}
// agent_message (final response text)
if (itemType === 'agent_message') {
if (eventType !== 'item.completed') return [];
const text = item.text as string || '';
if (!text.trim()) return [];
return [{ type: 'text', content: text }];
}
// command_execution
if (itemType === 'command_execution') {
const segments: AgentOutputSegment[] = [];
const command = item.command as string || '';
const output = item.aggregated_output as string || '';
const exitCode = item.exit_code as number | null;
if (eventType === 'item.started' && command) {
segments.push({ type: 'command', content: command });
}
if (eventType === 'item.completed') {
if (command) {
segments.push({ type: 'command', content: command });
}
if (output.trim()) {
segments.push({ type: 'command_output', content: output.trim() });
}
if (exitCode !== null && exitCode !== 0) {
segments.push({ type: 'error', content: `exit code: ${exitCode}` });
}
}
return segments;
}
// file_change
if (itemType === 'file_change') {
if (eventType !== 'item.completed') return [];
const changes = item.changes as Array<{ path: string; kind: string }> | undefined;
if (!changes?.length) return [];
const lines = changes.map(c => `${c.kind}: ${c.path}`).join('\n');
return [{ type: 'file_change', content: lines }];
}
// todo_list / plan
if (itemType === 'todo_list') {
const items = item.items as Array<{ text: string; completed: boolean }> | undefined;
if (!items?.length) return [];
const lines = items.map(t => `${t.completed ? '✓' : '○'} ${t.text}`).join('\n');
return [{ type: 'plan', content: lines }];
}
// mcp_tool_call
if (itemType === 'mcp_tool_call') {
const tool = item.tool as string || 'unknown';
const server = item.server as string || '';
if (eventType === 'item.started') {
return [{ type: 'command', content: `[MCP] ${server}/${tool}` }];
}
if (eventType === 'item.completed') {
const result = item.result as Record<string, unknown> | null;
const error = item.error as string | null;
if (error) {
return [{ type: 'error', content: `MCP ${tool}: ${error}` }];
}
if (result) {
const content = (result.content as Array<{ text?: string }>) || [];
const text = content.map(c => c.text || '').filter(Boolean).join('\n');
if (text) return [{ type: 'command_output', content: text }];
}
}
return [];
}
return [];
}
/**
* Format AgentOutputSegments into markdown text for display.
*/
export function formatSegmentsAsMarkdown(segments: AgentOutputSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'thinking':
return `> **Thinking:** ${seg.content}\n\n`;
case 'text':
return seg.content + '\n\n';
case 'command':
return `\`\`\`bash\n$ ${seg.content}\n\`\`\`\n\n`;
case 'command_output':
return `\`\`\`\n${seg.content}\n\`\`\`\n\n`;
case 'file_change':
return `**Files changed:**\n\`\`\`\n${seg.content}\n\`\`\`\n\n`;
case 'plan':
return `**Plan:**\n${seg.content}\n\n`;
case 'error':
return `**Error:** ${seg.content}\n\n`;
case 'usage':
return `---\n*${seg.content}*\n`;
default:
return seg.content;
}
}).join('');
}

View File

@@ -0,0 +1,216 @@
import type { ToolCall, ToolResult, AIPermissionMode } from '../types';
import {
executeTerminalExecute,
executeTerminalSendInput,
executeSftpListDirectory,
executeSftpReadFile,
executeSftpWriteFile,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeMultiHostExecute,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
/**
* Bridge interface for Catty Agent to interact with the Electron main process.
* This mirrors the AI-related subset of window.netcatty from electron/preload.cjs.
*/
export interface NetcattyBridge {
aiExec(
sessionId: string,
command: string,
): Promise<{
ok: boolean;
stdout?: string;
stderr?: string;
exitCode?: number;
error?: string;
}>;
aiTerminalWrite(
sessionId: string,
data: string,
): Promise<{ ok: boolean; error?: string }>;
listSftp(
sftpId: string,
path: string,
encoding?: string,
): Promise<unknown>;
readSftp(
sftpId: string,
path: string,
encoding?: string,
): Promise<string>;
writeSftp(
sftpId: string,
path: string,
content: string,
encoding?: string,
): Promise<void>;
}
// Workspace context provided to the executor
export interface ExecutorContext {
// Available sessions in scope
sessions: Array<{
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
sftpId?: string; // If SFTP is open for this session
}>;
// Workspace info
workspaceId?: string;
workspaceName?: string;
}
/** Convert a shared ToolExecResult into the executor's ToolResult format. */
function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
if (r.ok === false) {
return { toolCallId, content: r.error, isError: true };
}
// For terminal_execute, format as the legacy STDOUT/STDERR/exitCode text block
if (
typeof r.data === 'object' &&
r.data !== null &&
'stdout' in r.data &&
'stderr' in r.data &&
'exitCode' in r.data
) {
const d = r.data as { stdout: string; stderr: string; exitCode: number };
const output = [
d.stdout ? `STDOUT:\n${d.stdout}` : '',
d.stderr ? `STDERR:\n${d.stderr}` : '',
`Exit code: ${d.exitCode === -1 ? 'unknown' : d.exitCode}`,
]
.filter(Boolean)
.join('\n\n');
return { toolCallId, content: output || 'Command completed (no output)' };
}
// For terminal_send_input
if (typeof r.data === 'object' && r.data !== null && 'sent' in r.data) {
return { toolCallId, content: `Sent input to terminal: ${JSON.stringify((r.data as { sent: string }).sent)}` };
}
// For sftp_list_directory with output fallback
if (typeof r.data === 'object' && r.data !== null && 'output' in r.data && !('files' in r.data)) {
return { toolCallId, content: (r.data as { output: string }).output };
}
// For sftp_read_file
if (typeof r.data === 'object' && r.data !== null && 'content' in r.data) {
return { toolCallId, content: (r.data as { content: string }).content };
}
// For sftp_write_file
if (typeof r.data === 'object' && r.data !== null && 'written' in r.data) {
return { toolCallId, content: `File written: ${(r.data as { written: string }).written}` };
}
// Default: JSON-serialize the data
return { toolCallId, content: JSON.stringify(r.data, null, 2) };
}
/**
* Create a tool executor function for the Catty Agent.
* This bridges tool calls to the netcatty Electron IPC layer.
*/
export function createToolExecutor(
bridge: NetcattyBridge | undefined,
context: ExecutorContext,
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
): (toolCall: ToolCall) => Promise<ToolResult> {
return async (toolCall: ToolCall): Promise<ToolResult> => {
if (!bridge) {
return {
toolCallId: toolCall.id,
content: 'Netcatty bridge is not available',
isError: true,
};
}
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
const args = toolCall.arguments;
try {
switch (toolCall.name) {
case 'terminal_execute': {
const r = await executeTerminalExecute(deps, {
sessionId: String(args.sessionId || ''),
command: String(args.command || ''),
});
return toToolResult(toolCall.id, r);
}
case 'terminal_send_input': {
const r = await executeTerminalSendInput(deps, {
sessionId: String(args.sessionId || ''),
input: String(args.input || ''),
});
return toToolResult(toolCall.id, r);
}
case 'sftp_list_directory': {
const r = await executeSftpListDirectory(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || '/'),
});
return toToolResult(toolCall.id, r);
}
case 'sftp_read_file': {
const r = await executeSftpReadFile(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || ''),
maxBytes: Number(args.maxBytes) || 10000,
});
return toToolResult(toolCall.id, r);
}
case 'sftp_write_file': {
const r = await executeSftpWriteFile(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || ''),
content: String(args.content || ''),
});
return toToolResult(toolCall.id, r);
}
case 'workspace_get_info': {
const r = executeWorkspaceGetInfo(deps);
return toToolResult(toolCall.id, r);
}
case 'workspace_get_session_info': {
const r = executeWorkspaceGetSessionInfo(deps, {
sessionId: String(args.sessionId || ''),
});
return toToolResult(toolCall.id, r);
}
case 'multi_host_execute': {
const r = await executeMultiHostExecute(deps, {
sessionIds: (args.sessionIds as string[]) || [],
command: String(args.command || ''),
mode: String(args.mode || 'parallel'),
stopOnError: Boolean(args.stopOnError),
});
return toToolResult(toolCall.id, r);
}
default:
return {
toolCallId: toolCall.id,
content: `Unknown tool: ${toolCall.name}`,
isError: true,
};
}
} catch (err) {
return {
toolCallId: toolCall.id,
content: `Tool execution error: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
};
}

View File

@@ -0,0 +1,98 @@
import { DEFAULT_COMMAND_BLOCKLIST } from '../types';
/**
* Check if a regex pattern is safe from ReDoS attacks.
*
* Rejects patterns with nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`
* which can cause catastrophic backtracking / CPU exhaustion.
*/
function isSafeRegex(pattern: string): boolean {
// Detect nested quantifiers: a group containing a quantifier, followed by another quantifier.
// Matches patterns like (x+)+, (x*)+, (x+)*, (x{2,})+ etc.
const nestedQuantifier = /\([^)]*[+*}]\)[+*?{]/;
if (nestedQuantifier.test(pattern)) {
return false;
}
// Also catch overlapping alternations with quantifiers inside quantified groups
// e.g. (a|a)+ — not always dangerous but a common ReDoS vector
const overlappingAlt = /\([^)]*\|[^)]*\)[+*]{/;
if (overlappingAlt.test(pattern)) {
return false;
}
return true;
}
/**
* Pre-compiled RegExp cache for command blocklist patterns.
*
* The blocklist is a best-effort defense-in-depth measure. It is NOT a
* security boundary — determined users or sophisticated prompt injection
* can bypass regex-based filtering. The primary security boundary is the
* permission / confirmation system and OS-level sandboxing.
*/
const compiledDefaultBlocklist: RegExp[] = DEFAULT_COMMAND_BLOCKLIST.flatMap(
(pattern) => {
try {
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping default blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
return [];
}
return [new RegExp(pattern, 'i')];
} catch {
return [];
}
},
);
/** Cache for user-provided (non-default) blocklist patterns. */
const userPatternCache = new Map<string, RegExp | null>();
function getCompiledPattern(pattern: string): RegExp | null {
if (userPatternCache.has(pattern)) {
return userPatternCache.get(pattern)!;
}
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping user blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
userPatternCache.set(pattern, null);
return null;
}
try {
const regex = new RegExp(pattern, 'i');
userPatternCache.set(pattern, regex);
return regex;
} catch {
userPatternCache.set(pattern, null);
return null;
}
}
/**
* Check if a command matches any pattern in the blocklist.
* Returns the matching pattern if blocked, null if safe.
*
* Default blocklist patterns are pre-compiled at module load time.
* User-provided patterns are compiled once and cached.
*/
export function checkCommandSafety(
command: string,
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
): { blocked: boolean; matchedPattern?: string } {
// Fast path: use pre-compiled regexes for the default blocklist
if (blocklist === DEFAULT_COMMAND_BLOCKLIST) {
for (let i = 0; i < compiledDefaultBlocklist.length; i++) {
if (compiledDefaultBlocklist[i].test(command)) {
return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] };
}
}
return { blocked: false };
}
// User-provided blocklist: compile once and cache each pattern
for (const pattern of blocklist) {
const regex = getCompiledPattern(pattern);
if (regex && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
return { blocked: false };
}

View File

@@ -0,0 +1,128 @@
export interface SystemPromptContext {
scopeType: 'terminal' | 'workspace' | 'global';
scopeLabel?: string;
hosts: Array<{
sessionId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
const permissionRules = buildPermissionRules(permissionMode);
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users manage remote servers by executing commands, reading files, and performing batch operations across multiple hosts.
## Current Scope
${scopeDescription}
## Available Hosts
${hostList}
## Permission Mode: ${permissionMode}
${permissionRules}
## Guidelines
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing. Wait for acknowledgment on complex or risky operations.
2. **Use the right tool.** For operations that target multiple hosts, prefer \`multi_host_execute\` over calling \`terminal_execute\` repeatedly. For normal shell commands, use \`terminal_execute\` so you receive the command output. Use \`terminal_send_input\` only when responding to an interactive prompt that is already running in the terminal. \`terminal_send_input\` writes keystrokes but does not read back the updated terminal screen.
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
4. **Explain before executing.** Before running any command, briefly explain what it does and why. This is especially important for commands that modify the system.
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
7. **Respect connection status.** Only attempt operations on hosts that are currently connected. If a host is disconnected, inform the user and suggest reconnecting.
8. **Be careful with file operations.** When writing files via SFTP, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.`;
}
function buildScopeDescription(
scopeType: 'terminal' | 'workspace' | 'global',
scopeLabel?: string,
): string {
switch (scopeType) {
case 'terminal':
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific host.`;
case 'workspace':
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any host within this workspace.`;
case 'global':
return `You have global scope and can operate on any connected host across all workspaces.`;
}
}
function buildHostList(
hosts: SystemPromptContext['hosts'],
): string {
if (hosts.length === 0) {
return '_No hosts are currently available. The user needs to connect to a host first._';
}
const lines = hosts.map(host => {
const status = host.connected ? 'connected' : 'disconnected';
const details = [
`hostname: ${host.hostname}`,
`label: ${host.label}`,
host.os ? `os: ${host.os}` : null,
host.username ? `user: ${host.username}` : null,
`status: ${status}`,
]
.filter(Boolean)
.join(', ');
return `- \`${host.sessionId}\` - ${details}`;
});
return lines.join('\n');
}
function buildPermissionRules(
permissionMode: 'observer' | 'confirm' | 'autonomous',
): string {
switch (permissionMode) {
case 'observer':
return [
'You are in **observer** mode. You may only perform read-only operations:',
'- Listing directories (`sftp_list_directory`)',
'- Reading files (`sftp_read_file`)',
'- Getting workspace and session info (`workspace_get_info`, `workspace_get_session_info`)',
'',
'All write and execute operations are denied. If the user asks you to run a command or modify a file, explain that observer mode does not allow it and suggest switching to confirm or autonomous mode.',
].join('\n');
case 'confirm':
return [
'You are in **confirm** mode. Every write or execute operation requires explicit user approval before it runs:',
'- Command execution (`terminal_execute`, `multi_host_execute`)',
'- Sending terminal input (`terminal_send_input`)',
'- Writing files (`sftp_write_file`)',
'',
'Read-only operations are allowed without confirmation. When proposing a command, clearly state what it will do so the user can make an informed decision.',
].join('\n');
case 'autonomous':
return [
'You are in **autonomous** mode. You may execute commands and write files without explicit per-action approval, as long as they are not on the blocklist.',
'',
'Even in autonomous mode:',
'- Always present a plan for multi-step tasks before starting.',
'- Blocked commands are still denied regardless of mode.',
'- Exercise caution with destructive or irreversible operations.',
].join('\n');
}
}

View File

@@ -0,0 +1,24 @@
/**
* Run an array of async task factories with a concurrency limit.
*/
export async function limitConcurrency<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
const results: T[] = [];
const errors: Array<{ index: number; error: unknown }> = [];
const executing = new Set<Promise<void>>();
for (const [i, task] of tasks.entries()) {
const p: Promise<void> = task()
.then(r => { results[i] = r; })
.catch(err => { errors.push({ index: i, error: err }); })
.finally(() => executing.delete(p));
executing.add(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
if (errors.length > 0) {
const msgs = errors.map(e => `Task ${e.index}: ${e.error instanceof Error ? e.error.message : String(e.error)}`);
throw new AggregateError(errors.map(e => e.error), `${errors.length} task(s) failed: ${msgs.join('; ')}`);
}
return results;
}

View File

@@ -0,0 +1,125 @@
import type { AISession } from './types';
/**
* Export a session as Markdown
*/
export function exportAsMarkdown(session: AISession): string {
const lines: string[] = [];
lines.push(`# ${session.title || 'Untitled Chat'}`);
lines.push('');
lines.push(`- **Agent:** ${session.agentId}`);
lines.push(`- **Scope:** ${session.scope.type}${session.scope.targetId ? ` (${session.scope.targetId})` : ''}`);
lines.push(`- **Created:** ${new Date(session.createdAt).toLocaleString()}`);
lines.push(`- **Updated:** ${new Date(session.updatedAt).toLocaleString()}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of session.messages) {
if (msg.role === 'system') continue;
const time = new Date(msg.timestamp).toLocaleTimeString();
if (msg.role === 'user') {
lines.push(`## User [${time}]`);
lines.push('');
lines.push(msg.content);
lines.push('');
} else if (msg.role === 'assistant') {
lines.push(`## Assistant [${time}]${msg.model ? ` (${msg.model})` : ''}`);
lines.push('');
lines.push(msg.content);
if (msg.toolCalls?.length) {
lines.push('');
for (const tc of msg.toolCalls) {
lines.push(`### Tool Call: \`${tc.name}\``);
lines.push('');
lines.push('```json');
lines.push(JSON.stringify(tc.arguments, null, 2));
lines.push('```');
lines.push('');
}
}
lines.push('');
} else if (msg.role === 'tool') {
if (msg.toolResults?.length) {
for (const tr of msg.toolResults) {
lines.push(`### Tool Result${tr.isError ? ' (Error)' : ''}`);
lines.push('');
lines.push('```');
lines.push(tr.content);
lines.push('```');
lines.push('');
}
}
}
}
return lines.join('\n');
}
/**
* Export a session as JSON
*/
export function exportAsJSON(session: AISession): string {
return JSON.stringify(session, null, 2);
}
/**
* Export a session as plain text
*/
export function exportAsPlainText(session: AISession): string {
const lines: string[] = [];
lines.push(`Chat: ${session.title || 'Untitled'}`);
lines.push(`Date: ${new Date(session.createdAt).toLocaleString()}`);
lines.push('='.repeat(60));
lines.push('');
for (const msg of session.messages) {
if (msg.role === 'system') continue;
const time = new Date(msg.timestamp).toLocaleTimeString();
if (msg.role === 'user') {
lines.push(`[${time}] You:`);
lines.push(msg.content);
lines.push('');
} else if (msg.role === 'assistant') {
lines.push(`[${time}] Assistant:`);
lines.push(msg.content);
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
lines.push(` > Tool: ${tc.name}(${JSON.stringify(tc.arguments)})`);
}
}
lines.push('');
} else if (msg.role === 'tool') {
if (msg.toolResults?.length) {
for (const tr of msg.toolResults) {
lines.push(` > Result${tr.isError ? ' [ERROR]' : ''}:`);
lines.push(` ${tr.content}`);
}
lines.push('');
}
}
}
return lines.join('\n');
}
/**
* Generate a suggested filename for export
*/
export function getExportFilename(session: AISession, format: 'md' | 'json' | 'txt'): string {
const title = (session.title || 'chat')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40);
const date = new Date(session.createdAt).toISOString().slice(0, 10);
return `netcatty-${title}-${date}.${format}`;
}

View File

@@ -0,0 +1,68 @@
import type { ChatMessage } from './types';
/**
* Classifies a raw error string into structured error info for display.
*/
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
const lower = error.toLowerCase();
// Network errors
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
}
// Timeout
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
}
// Auth errors
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
}
// Rate limit
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
}
// Provider errors (5xx)
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
return { type: 'provider', message: 'The AI provider returned a server error. Please try again later.', retryable: true };
}
// Model not found
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
}
// Command blocked
if (lower.includes('blocked by safety')) {
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
}
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
}
const MAX_ERROR_MESSAGE_LENGTH = 500;
/**
* Sanitize an error message before displaying it to the user.
* Strips file paths, URLs with credentials, and truncates long messages.
*/
export function sanitizeErrorMessage(msg: string): string {
let sanitized = msg;
// Strip file system paths (Unix and Windows)
sanitized = sanitized.replace(/(?:\/Users\/|\/home\/|\/tmp\/|\/var\/|[A-Z]:\\)[^\s"'`,;)}\]>]*/gi, '<path>');
// Strip URLs containing API keys or tokens in query params
sanitized = sanitized.replace(/https?:\/\/[^\s"']*[?&](key|token|api_key|apikey|secret|access_token|auth)=[^\s"'&]*/gi, '<url-redacted>');
// Truncate overly long messages
if (sanitized.length > MAX_ERROR_MESSAGE_LENGTH) {
sanitized = sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '...';
}
return sanitized;
}

View File

@@ -0,0 +1,235 @@
import type {
ExternalAgentConfig,
} from './types';
import { parseAgentJsonLine, formatSegmentsAsMarkdown } from './agentOutputParser';
/** Callbacks for streaming external agent output */
export interface ExternalAgentCallbacks {
onTextDelta: (text: string) => void;
onError: (error: string) => void;
onDone: () => void;
}
/**
* Bridge interface matching the agent-related methods from window.netcatty
*/
interface AgentBridge {
aiSpawnAgent(
agentId: string,
command: string,
args?: string[],
env?: Record<string, string>,
options?: { closeStdin?: boolean },
): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
const PROMPT_PLACEHOLDER = '{prompt}';
/**
* Build the final command and args for an external agent.
*/
function buildAgentInvocation(
config: ExternalAgentConfig,
userMessage: string,
): { command: string; args: string[]; useStdin: boolean; jsonMode: boolean } {
const command = config.command;
const templateArgs = config.args || [];
const hasPlaceholder = templateArgs.some(a => a.includes(PROMPT_PLACEHOLDER));
const jsonMode = templateArgs.includes('--json');
if (hasPlaceholder) {
const args = templateArgs.map(a =>
a === PROMPT_PLACEHOLDER ? userMessage : a.replaceAll(PROMPT_PLACEHOLDER, userMessage),
);
return { command, args, useStdin: false, jsonMode };
}
return { command, args: [...templateArgs], useStdin: true, jsonMode };
}
/**
* Creates a stdout handler that parses JSON Lines (for --json mode agents)
* and converts structured events to formatted markdown text.
*
* Handles partial lines since stdout chunks can split mid-line.
*/
function createJsonLinesHandler(onText: (text: string) => void): (data: string) => void {
let lineBuffer = '';
// Track seen item IDs to avoid duplicating command blocks
// (item.started shows the command, item.completed shows command + output)
const seenCommands = new Set<string>();
return (data: string) => {
lineBuffer += data;
const lines = lineBuffer.split('\n');
// Keep the last (possibly incomplete) line in the buffer
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const segments = parseAgentJsonLine(line);
if (segments === null) {
// Not JSON — pass through as plain text
onText(line + '\n');
continue;
}
if (segments.length === 0) continue;
// Deduplicate command_execution: skip started if we'll get completed
const filtered = segments.filter(seg => {
if (seg.type === 'command') {
if (seenCommands.has(seg.content)) return false;
seenCommands.add(seg.content);
}
return true;
});
if (filtered.length > 0) {
const markdown = formatSegmentsAsMarkdown(filtered);
onText(markdown);
}
}
};
}
/**
* Start an external agent and send a message through it.
*/
export async function runExternalAgentTurn(
config: ExternalAgentConfig,
userMessage: string,
callbacks: ExternalAgentCallbacks,
bridge: AgentBridge | undefined,
signal?: AbortSignal,
): Promise<void> {
if (!bridge) {
callbacks.onError('Bridge not available');
return;
}
const agentId = `ext_${config.id}_${Date.now()}`;
const { command, args, useStdin, jsonMode } = buildAgentInvocation(config, userMessage);
const cleanupFns: (() => void)[] = [];
let done = false;
const finish = () => {
if (done) return;
done = true;
for (const fn of cleanupFns) {
try { fn(); } catch { /* cleanup */ }
}
callbacks.onDone();
};
// ── Set up event listeners BEFORE spawning to avoid race condition ──
// For JSON mode, parse structured events; otherwise, pass through raw text
const stdoutHandler = jsonMode
? createJsonLinesHandler((text) => { if (!done) callbacks.onTextDelta(text); })
: (data: string) => { if (!done) callbacks.onTextDelta(data); };
const unsubStdout = bridge.onAiAgentStdout(agentId, stdoutHandler);
cleanupFns.push(unsubStdout);
// Collect stderr
let stderrBuffer = '';
const unsubStderr = bridge.onAiAgentStderr(agentId, (data) => {
stderrBuffer += data;
});
cleanupFns.push(unsubStderr);
let resolveExit: (code: number | null) => void;
const exitPromise = new Promise<number | null>((resolve) => {
resolveExit = resolve;
const unsubExit = bridge.onAiAgentExit(agentId, (code) => {
resolve(code);
});
cleanupFns.push(unsubExit);
});
// Handle abort
if (signal) {
if (signal.aborted) {
finish();
return;
}
const onAbort = () => {
bridge.aiKillAgent(agentId).catch(() => {});
callbacks.onError('Cancelled');
resolveExit(null);
finish();
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
}
// ── Spawn the process ──
const result = await bridge.aiSpawnAgent(
agentId,
command,
args,
config.env,
{ closeStdin: !useStdin },
);
if (!result.ok) {
callbacks.onError(`Failed to start ${config.name}: ${result.error}`);
finish();
return;
}
// Send the user message via stdin if needed, then close stdin (EOF)
if (useStdin) {
try {
await bridge.aiWriteToAgent(agentId, userMessage + '\n');
await bridge.aiCloseAgentStdin(agentId);
} catch (err) {
callbacks.onError(`Failed to write to agent: ${err}`);
finish();
return;
}
}
// Timeout after 5 minutes
const timeout = setTimeout(() => {
if (!done) {
bridge.aiKillAgent(agentId).catch(() => {});
callbacks.onError('Agent timeout (5 minutes)');
resolveExit(null);
finish();
}
}, 300000);
cleanupFns.push(() => clearTimeout(timeout));
// Wait for the process to exit
const exitCode = await exitPromise;
// If process exited with error and no stdout was received, report stderr
if (exitCode !== 0 && stderrBuffer.trim() && !done) {
callbacks.onError(stderrBuffer.trim());
}
finish();
}
/**
* Kill a running external agent session
*/
export async function killExternalAgent(
agentId: string,
bridge: AgentBridge | undefined,
): Promise<void> {
if (bridge) {
await bridge.aiKillAgent(agentId).catch(() => {});
}
}

View File

@@ -0,0 +1,277 @@
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import type { ProviderConfig } from '../types';
/**
* Bridge API subset used for SDK fetch adapter.
*/
interface BridgeAPI {
aiFetch(
url: string,
method: string,
headers: Record<string, string>,
body?: string,
providerId?: string,
): Promise<{
ok: boolean;
status: number;
data: string;
error?: string;
}>;
aiChatStream(
requestId: string,
url: string,
headers: Record<string, string>,
body: string,
providerId?: string,
): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
onAiStreamData(requestId: string, cb: (data: string) => void): () => void;
onAiStreamEnd(requestId: string, cb: () => void): () => void;
onAiStreamError(requestId: string, cb: (error: string) => void): () => void;
aiChatCancel(requestId: string): Promise<boolean>;
}
function getBridge(): BridgeAPI | null {
const w = window as unknown as { netcatty?: BridgeAPI };
return w.netcatty ?? null;
}
/**
* Detect whether a request is likely a streaming request.
* AI SDK streaming requests use POST with `"stream": true` in the body.
*/
function isStreamingRequest(init?: RequestInit): boolean {
if (!init?.body) return false;
try {
const bodyStr = typeof init.body === 'string' ? init.body : null;
if (!bodyStr) return false;
const parsed = JSON.parse(bodyStr);
return parsed.stream === true;
} catch {
return false;
}
}
/**
* Extract headers as a plain Record<string, string> from various header formats.
*/
function extractHeaders(headers?: HeadersInit): Record<string, string> {
const result: Record<string, string> = {};
if (!headers) return result;
if (headers instanceof Headers) {
headers.forEach((value, key) => {
result[key] = value;
});
} else if (Array.isArray(headers)) {
for (const [key, value] of headers) {
result[key] = value;
}
} else {
Object.assign(result, headers);
}
return result;
}
/**
* Create a fetch function compatible with the Vercel AI SDK that routes
* requests through the Electron IPC bridge to avoid CORS.
*
* - Non-streaming requests: uses `window.netcatty.aiFetch()` and returns a `Response`.
* - Streaming requests: uses `window.netcatty.aiChatStream()` and returns a
* `Response` with a `ReadableStream` body.
* - Falls back to `globalThis.fetch` if the bridge is unavailable.
*/
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
return async (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
const bridge = getBridge();
if (!bridge) {
return globalThis.fetch(input, init);
}
// Resolve URL string
let url: string;
let resolvedInit = init;
if (input instanceof Request) {
url = input.url;
// Merge Request properties with init overrides
if (!resolvedInit) {
resolvedInit = {
method: input.method,
headers: extractHeaders(input.headers),
body: input.body ? await new Response(input.body).text() : undefined,
};
}
} else {
url = input instanceof URL ? input.toString() : input;
}
const method = resolvedInit?.method || 'GET';
const headers = extractHeaders(resolvedInit?.headers);
const body =
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// Set up IPC event listeners BEFORE starting the stream to avoid
// missing early events.
const encoder = new TextEncoder();
let streamController: ReadableStreamDefaultController<Uint8Array>;
let cleanedUp = false;
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
// Re-wrap as SSE so the SDK can parse it
streamController?.enqueue(encoder.encode(`data: ${data}\n\n`));
});
const unsubEnd = bridge.onAiStreamEnd(requestId, () => {
try { streamController?.close(); } catch { /* already closed */ }
cleanup();
});
const unsubError = bridge.onAiStreamError(
requestId,
(error: string) => {
try { streamController?.error(new Error(error)); } catch { /* already errored */ }
cleanup();
},
);
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
unsubData();
unsubEnd();
unsubError();
};
// Handle abort
if (resolvedInit?.signal) {
resolvedInit.signal.addEventListener(
'abort',
() => {
bridge.aiChatCancel(requestId).catch(() => {});
try { streamController?.error(new DOMException('Aborted', 'AbortError')); } catch { /* already errored */ }
cleanup();
},
{ once: true },
);
}
// Start the stream — resolves once HTTP response headers arrive,
// returning the real status code.
const result = await bridge.aiChatStream(
requestId,
url,
headers,
body || '',
providerId,
);
if (!result.ok) {
cleanup();
return new Response(result.error || 'Stream request failed', {
status: 502,
statusText: 'Bad Gateway',
});
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
});
return new Response(stream, {
status: result.statusCode ?? 200,
statusText: result.statusText ?? 'OK',
headers: { 'content-type': 'text/event-stream' },
});
}
// Non-streaming path
const result = await bridge.aiFetch(url, method, headers, body, providerId);
return new Response(result.data, {
status: result.status,
statusText: result.ok ? 'OK' : 'Error',
headers: { 'content-type': 'application/json' },
});
};
}
/**
* Create a Vercel AI SDK model instance from a ProviderConfig.
*
* API keys are NOT sent to the SDK in plaintext. Instead, a placeholder
* token is used so the SDK builds proper auth headers, and the main
* process replaces the placeholder with the real decrypted key before
* making the HTTP request.
*/
export function createModelFromConfig(config: ProviderConfig) {
// Use placeholder API key — the main process will inject the real key
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
const customFetch = createBridgeFetchForSDK(config.id);
const modelId = config.defaultModel || '';
switch (config.providerId) {
case 'openai':
// Use .chat() to force Chat Completions API (not Responses API)
return createOpenAI({
apiKey: safeApiKey,
baseURL: config.baseURL,
fetch: customFetch,
}).chat(modelId);
case 'anthropic':
return createAnthropic({
apiKey: safeApiKey,
baseURL: config.baseURL,
fetch: customFetch,
})(modelId);
case 'google':
return createGoogleGenerativeAI({
apiKey: safeApiKey,
baseURL: config.baseURL,
fetch: customFetch,
})(modelId);
case 'ollama':
// Ollama uses OpenAI-compatible Chat Completions API
return createOpenAI({
apiKey: 'ollama',
baseURL: config.baseURL || 'http://localhost:11434/v1',
fetch: customFetch,
}).chat(modelId);
case 'openrouter':
// OpenRouter uses OpenAI-compatible Chat Completions API
return createOpenAI({
apiKey: safeApiKey,
baseURL: config.baseURL || 'https://openrouter.ai/api/v1',
fetch: customFetch,
}).chat(modelId);
case 'custom':
// Custom providers use OpenAI-compatible Chat Completions API
return createOpenAI({
apiKey: safeApiKey,
baseURL: config.baseURL,
fetch: customFetch,
}).chat(modelId);
default: {
const _exhaustive: never = config.providerId;
throw new Error(`Unsupported provider: ${_exhaustive}`);
}
}
}

View File

@@ -0,0 +1,178 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
import type { AIPermissionMode } from '../types';
import {
executeTerminalExecute,
executeTerminalSendInput,
executeSftpListDirectory,
executeSftpReadFile,
executeSftpWriteFile,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeMultiHostExecute,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
/** Unwrap a shared ToolExecResult into the shape expected by Vercel AI SDK tool results. */
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
if (r.ok === false) return { error: r.error };
return r.data;
}
/**
* Create Catty Agent tools using the Vercel AI SDK `tool()` helper with zod schemas.
*
* @param bridge - The Electron IPC bridge for executing operations
* @param context - Workspace/session context available to the agent
* @param commandBlocklist - Optional command blocklist patterns for safety checks
* @param permissionMode - Permission mode for tool execution gating
*/
export function createCattyTools(
bridge: NetcattyBridge,
context: ExecutorContext,
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
) {
const writeToolNeedsApproval = permissionMode === 'confirm';
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
return {
terminal_execute: tool({
description:
'Execute a shell command on a remote host via the specified terminal session. ' +
"The command runs in the session's shell and output is returned when complete.",
inputSchema: z.object({
sessionId: z.string().describe('The terminal session ID to execute the command on.'),
command: z.string().describe('The shell command to execute on the remote host.'),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, command }) => {
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
},
}),
terminal_send_input: tool({
description:
'Send raw input to a terminal session. Use this for interactive programs that ' +
'require input such as y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), ' +
'or any other keyboard input. This tool only sends input; it does not return ' +
'the updated terminal output. For normal shell commands, use terminal_execute instead.',
inputSchema: z.object({
sessionId: z.string().describe('The terminal session ID to send input to.'),
input: z
.string()
.describe(
'The raw input string to send. Use escape sequences for special keys ' +
'(e.g. "\\x03" for ctrl+c, "\\n" for enter).',
),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, input }) => {
return unwrap(await executeTerminalSendInput(deps, { sessionId, input }));
},
}),
sftp_list_directory: tool({
description:
'List the contents of a directory on the remote host via SFTP. Returns file names, ' +
'sizes, types, and modification timestamps.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote directory to list.'),
}),
execute: async ({ sessionId, path }) => {
return unwrap(await executeSftpListDirectory(deps, { sessionId, path }));
},
}),
sftp_read_file: tool({
description:
'Read the content of a file on the remote host via SFTP. Returns the file content ' +
'as text, truncated to maxBytes if the file is large.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote file to read.'),
maxBytes: z
.number()
.optional()
.default(10000)
.describe('Maximum number of bytes to read from the file. Defaults to 10000.'),
}),
execute: async ({ sessionId, path, maxBytes }) => {
return unwrap(await executeSftpReadFile(deps, { sessionId, path, maxBytes }));
},
}),
sftp_write_file: tool({
description:
'Write content to a file on the remote host via SFTP. Creates the file if it does ' +
'not exist, or overwrites it if it does.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote file to write.'),
content: z.string().describe('The text content to write to the file.'),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, path, content }) => {
return unwrap(await executeSftpWriteFile(deps, { sessionId, path, content }));
},
}),
workspace_get_info: tool({
description:
'Get information about the current workspace, including all configured hosts ' +
'and their connection status. No parameters required.',
inputSchema: z.object({}),
execute: async () => {
return unwrap(executeWorkspaceGetInfo(deps));
},
}),
workspace_get_session_info: tool({
description:
'Get detailed information about a specific terminal or SFTP session, including ' +
'the host it is connected to, connection status, and session metadata.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID to get information about.'),
}),
execute: async ({ sessionId }) => {
return unwrap(executeWorkspaceGetSessionInfo(deps, { sessionId }));
},
}),
multi_host_execute: tool({
description:
'Execute a command on multiple hosts simultaneously or sequentially. ' +
'Use this for batch operations such as checking status across a fleet, ' +
'deploying updates, or running maintenance tasks on multiple servers.',
inputSchema: z.object({
sessionIds: z
.array(z.string())
.describe('Array of session IDs to execute the command on.'),
command: z.string().describe('The shell command to execute on each host.'),
mode: z
.enum(['parallel', 'sequential'])
.optional()
.default('parallel')
.describe(
'Execution mode. "parallel" runs on all hosts at once, ' +
'"sequential" runs one at a time. Defaults to "parallel".',
),
stopOnError: z
.boolean()
.optional()
.default(false)
.describe(
'If true and mode is "sequential", stop executing on remaining hosts ' +
'when a command fails. Defaults to false.',
),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionIds, command, mode, stopOnError }) => {
return unwrap(await executeMultiHostExecute(deps, { sessionIds, command, mode, stopOnError }));
},
}),
};
}

View File

@@ -0,0 +1,322 @@
/**
* Shared tool execution logic used by both the Catty Agent executor (switch/case)
* and the Vercel AI SDK tool wrappers.
*
* Each function encapsulates the core business logic for a tool — validation,
* safety checks, bridge calls, and result formatting — so callers only need to
* adapt the return value to their own response shape.
*/
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
import type { AIPermissionMode } from '../types';
import { checkCommandSafety } from '../cattyAgent/safety';
import { shellQuote } from '../shellQuote';
import { limitConcurrency } from '../concurrency';
// ---------------------------------------------------------------------------
// Shared result types
// ---------------------------------------------------------------------------
/** Discriminated union returned by every shared executor. */
export type ToolExecResult<T = unknown> =
| { ok: true; data: T }
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Dependencies bundle
// ---------------------------------------------------------------------------
export interface ToolDeps {
bridge: NetcattyBridge;
context: ExecutorContext;
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function validSessionIds(ctx: ExecutorContext): Set<string> {
return new Set(ctx.sessions.map(s => s.sessionId));
}
function validateSessionScope(ctx: ExecutorContext, sessionId: string): string | null {
const ids = validSessionIds(ctx);
if (!ids.has(sessionId)) {
return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`;
}
return null;
}
function isObserver(mode: AIPermissionMode): boolean {
return mode === 'observer';
}
// ---------------------------------------------------------------------------
// Tool executors
// ---------------------------------------------------------------------------
export async function executeTerminalExecute(
deps: ToolDeps,
args: { sessionId: string; command: string },
): Promise<ToolExecResult<{ stdout: string; stderr: string; exitCode: number }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionId, command } = args;
if (!sessionId || !command) {
return { ok: false, error: 'Missing sessionId or command' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode to execute commands.' };
}
const safety = checkCommandSafety(command, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const result = await bridge.aiExec(sessionId, command);
if (!result.ok) {
return { ok: false, error: result.error || 'Command failed' };
}
return {
ok: true,
data: {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.exitCode ?? -1,
},
};
}
export async function executeTerminalSendInput(
deps: ToolDeps,
args: { sessionId: string; input: string },
): Promise<ToolExecResult<{ sent: string }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionId, input } = args;
if (!sessionId || !input) {
return { ok: false, error: 'Missing sessionId or input' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: terminal input is disabled. Switch to Confirm or Auto mode.' };
}
const safety = checkCommandSafety(input, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Input blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const result = await bridge.aiTerminalWrite(sessionId, input);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to send input' };
}
return { ok: true, data: { sent: input } };
}
export async function executeSftpListDirectory(
deps: ToolDeps,
args: { sessionId: string; path: string },
): Promise<ToolExecResult<{ files?: unknown; output?: string }>> {
const { bridge, context } = deps;
const { sessionId, path } = args;
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
// Fallback: use terminal exec with ls
const result = await bridge.aiExec(sessionId, `ls -la ${shellQuote(path)}`);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to list directory' };
}
return { ok: true, data: { output: result.stdout || '(empty directory)' } };
}
const files = await bridge.listSftp(session.sftpId, path);
return { ok: true, data: { files } };
}
export async function executeSftpReadFile(
deps: ToolDeps,
args: { sessionId: string; path: string; maxBytes?: number },
): Promise<ToolExecResult<{ content: string }>> {
const { bridge, context } = deps;
const { sessionId, path } = args;
if (!sessionId || !path) {
return { ok: false, error: 'Missing sessionId or path' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
const clampedMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
const result = await bridge.aiExec(sessionId, `head -c ${clampedMaxBytes} ${shellQuote(path)}`);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to read file' };
}
return { ok: true, data: { content: result.stdout || '(empty file)' } };
}
let content = await bridge.readSftp(session.sftpId, path);
const maxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
if (content && content.length > maxBytes) {
content = content.slice(0, maxBytes);
}
return { ok: true, data: { content: content || '(empty file)' } };
}
export async function executeSftpWriteFile(
deps: ToolDeps,
args: { sessionId: string; path: string; content: string },
): Promise<ToolExecResult<{ written: string }>> {
const { bridge, context, permissionMode } = deps;
const { sessionId, path, content } = args;
if (!sessionId || !path) {
return { ok: false, error: 'Missing sessionId or path' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: file writing is disabled. Switch to Confirm or Auto mode.' };
}
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
// Fallback: base64 encoding to avoid heredoc injection
const b64 = typeof btoa === 'function'
? btoa(unescape(encodeURIComponent(content)))
: Buffer.from(content, 'utf-8').toString('base64');
const result = await bridge.aiExec(
sessionId,
`echo ${shellQuote(b64)} | base64 -d > ${shellQuote(path)}`,
);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to write file' };
}
return { ok: true, data: { written: path } };
}
await bridge.writeSftp(session.sftpId, path, content);
return { ok: true, data: { written: path } };
}
export function executeWorkspaceGetInfo(
deps: ToolDeps,
): ToolExecResult<{
workspaceId: string | null;
workspaceName: string | null;
sessions: Array<{
sessionId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}>;
}> {
const { context } = deps;
return {
ok: true,
data: {
workspaceId: context.workspaceId || null,
workspaceName: context.workspaceName || null,
sessions: context.sessions.map(s => ({
sessionId: s.sessionId,
hostname: s.hostname,
label: s.label,
os: s.os,
username: s.username,
connected: s.connected,
})),
},
};
}
export function executeWorkspaceGetSessionInfo(
deps: ToolDeps,
args: { sessionId: string },
): ToolExecResult<ExecutorContext['sessions'][number]> {
const { context } = deps;
const session = context.sessions.find(s => s.sessionId === args.sessionId);
if (!session) {
return { ok: false, error: `Session not found: ${args.sessionId}` };
}
return { ok: true, data: session };
}
export async function executeMultiHostExecute(
deps: ToolDeps,
args: {
sessionIds: string[];
command: string;
mode?: string;
stopOnError?: boolean;
},
): Promise<ToolExecResult<{ results: Record<string, { ok: boolean; output: string }> }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionIds, command, mode = 'parallel', stopOnError = false } = args;
if (sessionIds.length === 0 || !command) {
return { ok: false, error: 'Missing sessionIds or command' };
}
const currentValidIds = validSessionIds(context);
const outOfScope = sessionIds.filter(sid => !currentValidIds.has(sid));
if (outOfScope.length > 0) {
return {
ok: false,
error: `Sessions not in current scope: ${outOfScope.join(', ')}. Available sessions: ${[...currentValidIds].join(', ')}`,
};
}
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode.' };
}
const safety = checkCommandSafety(command, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const results: Record<string, { ok: boolean; output: string }> = {};
if (mode === 'sequential') {
for (const sid of sessionIds) {
const session = context.sessions.find(s => s.sessionId === sid);
const label = session?.label || sid;
const result = await bridge.aiExec(sid, command);
results[label] = {
ok: result.ok,
output: result.ok
? result.stdout || '(no output)'
: `Error: ${result.error || result.stderr || 'Failed'}`,
};
if (!result.ok && stopOnError) break;
}
} else {
const tasks = sessionIds.map((sid) => () => {
const session = context.sessions.find(s => s.sessionId === sid);
const label = session?.label || sid;
return bridge.aiExec(sid, command).then(result => ({
label,
ok: result.ok,
output: result.ok
? result.stdout || '(no output)'
: `Error: ${result.error || result.stderr || 'Failed'}`,
}));
});
const resolved = await limitConcurrency(tasks, 10);
for (const r of resolved) {
results[r.label] = { ok: r.ok, output: r.output };
}
}
return { ok: true, data: { results } };
}

View File

@@ -0,0 +1,9 @@
/**
* Safely quote a string for use in a POSIX shell command.
* Wraps the value in single quotes and escapes any embedded single quotes.
*
* Example: shellQuote("hello 'world'") => "'hello '\\''world'\\'''"
*/
export function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}

256
infrastructure/ai/types.ts Normal file
View File

@@ -0,0 +1,256 @@
// AI Provider types
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
export interface ProviderConfig {
id: string;
providerId: AIProviderId;
name: string;
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
baseURL?: string; // custom endpoint URL
defaultModel?: string;
customHeaders?: Record<string, string>;
enabled: boolean;
}
export interface ModelInfo {
id: string;
name: string;
providerId: AIProviderId;
contextWindow?: number;
supportsTools?: boolean;
supportsStreaming?: boolean;
}
// Chat types
export interface ChatMessageImage {
base64Data: string;
mediaType: string;
filename?: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
images?: ChatMessageImage[];
thinking?: string;
thinkingDurationMs?: number;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
timestamp: number;
model?: string;
providerId?: AIProviderId;
errorInfo?: {
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
message: string;
retryable: boolean;
};
/** Transient status text shown with shimmer effect (e.g. "Waiting for response...") */
statusText?: string;
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed';
pendingApproval?: {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
};
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
export interface ToolResult {
toolCallId: string;
content: string;
isError?: boolean;
}
export interface ChatParams {
model: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
}
// Streaming events
export type ChatStreamEvent =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'tool_call'; toolCall: ToolCall }
| { type: 'error'; error: string }
| { type: 'done'; usage?: { promptTokens: number; completionTokens: number } };
// AI Session types
export interface AISession {
id: string;
title: string;
agentId: string;
scope: AISessionScope;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}
export interface AISessionScope {
type: 'terminal' | 'workspace' | 'global';
targetId?: string; // sessionId or workspaceId
hostIds?: string[]; // resolved host IDs in scope
}
// Permission model
export type AIPermissionMode = 'observer' | 'confirm' | 'autonomous';
export interface HostAIPermission {
hostId: string;
mode: AIPermissionMode;
allowedCommands?: string[]; // regex patterns
blockedCommands?: string[]; // regex patterns
allowFileWrite?: boolean;
maxConcurrentCommands?: number;
}
// Agent types
export interface AgentInfo {
id: string;
name: string;
type: 'builtin' | 'external';
icon?: string;
description?: string;
command?: string; // for external agents
args?: string[];
available: boolean;
}
// External Agent (ACP) config
export interface ExternalAgentConfig {
id: string;
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
icon?: string;
enabled: boolean;
/** ACP command (e.g. 'codex-acp', 'claude-code-acp', 'gemini --experimental-acp') */
acpCommand?: string;
acpArgs?: string[];
}
// Discovered agent from system PATH
export interface DiscoveredAgent {
command: string;
name: string;
icon: string;
description: string;
args: string[];
path: string;
version: string;
available: boolean;
/** ACP command if agent supports ACP protocol */
acpCommand?: string;
acpArgs?: string[];
}
// AI Settings (stored in localStorage)
export interface AISettings {
providers: ProviderConfig[];
activeProviderId: string;
activeModelId: string;
globalPermissionMode: AIPermissionMode;
externalAgents: ExternalAgentConfig[];
defaultAgentId: string;
commandBlocklist: string[]; // global command blocklist patterns
commandTimeout: number; // seconds, default 60
maxIterations: number; // doom loop prevention, default 20
}
export const DEFAULT_COMMAND_BLOCKLIST = [
// rm with recursive+force in any order/form targeting root
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
'\\bmkfs\\.',
'\\bdd\\s+if=.*\\s+of=/dev/',
'\\b(shutdown|reboot|poweroff|halt)\\b',
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:', // fork bomb
'>\\s*/dev/sd',
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
'\\bmv\\s+/\\s',
':\\s*>\\s*/etc/',
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b', // piped install with sudo
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
// Common bypass techniques (defense-in-depth, not a security boundary)
'base64.*\\|.*(?:ba)?sh', // base64 decode piped to shell
'\\beval\\b', // eval usage
'\\$\\(', // command substitution abuse
'`.+`', // backtick command substitution
];
export const DEFAULT_AI_SETTINGS: AISettings = {
providers: [],
activeProviderId: '',
activeModelId: '',
globalPermissionMode: 'confirm',
externalAgents: [],
defaultAgentId: 'catty',
commandBlocklist: [...DEFAULT_COMMAND_BLOCKLIST],
commandTimeout: 60,
maxIterations: 20,
};
// Provider presets for quick setup
export const PROVIDER_PRESETS: Record<AIProviderId, { name: string; defaultBaseURL: string; modelsEndpoint?: string }> = {
openai: { name: 'OpenAI', defaultBaseURL: 'https://api.openai.com/v1', modelsEndpoint: '/models' },
anthropic: { name: 'Anthropic', defaultBaseURL: 'https://api.anthropic.com', modelsEndpoint: '/v1/models' },
google: { name: 'Google AI', defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
ollama: { name: 'Ollama', defaultBaseURL: 'http://localhost:11434/v1', modelsEndpoint: '/models' },
openrouter: { name: 'OpenRouter', defaultBaseURL: 'https://openrouter.ai/api/v1', modelsEndpoint: '/models' },
custom: { name: 'Custom', defaultBaseURL: '' },
};
// Agent model presets (hardcoded, same as 1code)
export interface AgentModelPreset {
id: string;
name: string;
description?: string;
/** Codex thinking levels (model ID sent as `id/thinking`) */
thinkingLevels?: string[];
}
export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'default', name: 'Opus 4.6', description: 'Recommended' },
{ id: 'sonnet', name: 'Sonnet 4.6', description: 'Everyday tasks' },
{ id: 'haiku', name: 'Haiku 4.5', description: 'Fastest' },
];
export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'gpt-5.4', name: 'GPT 5.4', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.3-codex', name: 'Codex 5.3', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.2-codex', name: 'Codex 5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.1-codex-max', name: 'Codex 5.1 Max', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.1-codex-mini', name: 'Codex 5.1 Mini', description: 'Fast', thinkingLevels: ['medium', 'high'] },
{ id: 'o3', name: 'o3', description: 'Reasoning' },
{ id: 'o4-mini', name: 'o4-mini', description: 'Fast reasoning' },
];
export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[] {
if (!agentCommand) return [];
const basename = agentCommand.split('/').pop()?.toLowerCase() ?? '';
if (basename.startsWith('claude')) return CLAUDE_MODEL_PRESETS;
if (basename.startsWith('codex')) return CODEX_MODEL_PRESETS;
return [];
}
export function formatThinkingLabel(level: string): string {
if (level === 'xhigh') return 'Extra High';
return level.charAt(0).toUpperCase() + level.slice(1);
}

View File

@@ -38,6 +38,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
@@ -68,6 +69,21 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
// Global Toggle Window Settings (Quake Mode)
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
// AI Settings
export const STORAGE_KEY_AI_PROVIDERS = 'netcatty_ai_providers_v1';
export const STORAGE_KEY_AI_ACTIVE_PROVIDER = 'netcatty_ai_active_provider_v1';
export const STORAGE_KEY_AI_ACTIVE_MODEL = 'netcatty_ai_active_model_v1';
export const STORAGE_KEY_AI_PERMISSION_MODE = 'netcatty_ai_permission_mode_v1';
export const STORAGE_KEY_AI_HOST_PERMISSIONS = 'netcatty_ai_host_permissions_v1';
export const STORAGE_KEY_AI_EXTERNAL_AGENTS = 'netcatty_ai_external_agents_v1';
export const STORAGE_KEY_AI_DEFAULT_AGENT = 'netcatty_ai_default_agent_v1';
export const STORAGE_KEY_AI_COMMAND_BLOCKLIST = 'netcatty_ai_command_blocklist_v1';
export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';

View File

@@ -7,18 +7,40 @@ const safeParse = <T>(value: string | null): T | null => {
}
};
/**
* Safely write to localStorage, catching QuotaExceededError.
* Returns true if the write succeeded, false if storage quota was exceeded.
*/
function safeSetItem(key: string, value: string): boolean {
try {
localStorage.setItem(key, value);
return true;
} catch (err) {
if (
err instanceof DOMException &&
(err.name === 'QuotaExceededError' || err.code === 22)
) {
console.warn(
`[localStorageAdapter] QuotaExceededError writing key "${key}" (${value.length} chars). Data was not persisted.`,
);
return false;
}
throw err; // Re-throw unexpected errors
}
}
export const localStorageAdapter = {
read<T>(key: string): T | null {
return safeParse<T>(localStorage.getItem(key));
},
write<T>(key: string, value: T) {
localStorage.setItem(key, JSON.stringify(value));
write<T>(key: string, value: T): boolean {
return safeSetItem(key, JSON.stringify(value));
},
readString(key: string): string | null {
return localStorage.getItem(key);
},
writeString(key: string, value: string) {
localStorage.setItem(key, value);
writeString(key: string, value: string): boolean {
return safeSetItem(key, value);
},
readBoolean(key: string): boolean | null {
const value = localStorage.getItem(key);
@@ -27,8 +49,8 @@ export const localStorageAdapter = {
if (value === "false") return false;
return null;
},
writeBoolean(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
writeBoolean(key: string, value: boolean): boolean {
return safeSetItem(key, value ? "true" : "false");
},
readNumber(key: string): number | null {
const value = localStorage.getItem(key);
@@ -36,8 +58,8 @@ export const localStorageAdapter = {
const num = parseInt(value, 10);
return isNaN(num) ? null : num;
},
writeNumber(key: string, value: number) {
localStorage.setItem(key, String(value));
writeNumber(key: string, value: number): boolean {
return safeSetItem(key, String(value));
},
remove(key: string) {
localStorage.removeItem(key);

3255
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,10 +28,14 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@aws-sdk/client-s3": "^3.956.0",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@mcpc-tech/acp-ai-provider": "0.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
@@ -43,25 +47,32 @@
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@zed-industries/codex-acp": "0.10.0",
"ai": "^6.0.116",
"clsx": "2.1.1",
"electron-updater": "^6.8.3",
"iconv-lite": "^0.6.3",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
"node-pty": "1.1.0-beta19",
"node-pty": "1.1.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"serialport": "^13.0.0",
"ssh2-sftp-client": "^12.0.1",
"streamdown": "^2.4.0",
"tailwind-merge": "3.4.0",
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0"
"webdav": "^5.8.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

12
public/ai/agents/atom.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-atom-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M12 21l0 .01" />
<path d="M3 9l0 .01" />
<path d="M21 9l0 .01" />
<path d="M8 20.1a9 9 0 0 1 -5 -7.1" />
<path d="M16 20.1a9 9 0 0 0 5 -7.1" />
<path d="M6.2 5a9 9 0 0 1 11.4 0" />
</svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="150 25 680 950" fill="currentColor"><path d="M467.1488 555.52a48.0768 48.0768 0 1 0-48.0256-48.0256 48.0256 48.0256 0 0 0 48.0256 48.0256zM602.0608 463.5136c-23.4496 26.9312-46.7968 68.6592 6.0928 64.512s59.2896-25.6 57.0368-71.168-38.5024-21.6576-63.1296 6.656zM615.1168 383.3344a45.5168 45.5168 0 1 0-45.4656-45.4144 45.5168 45.5168 0 0 0 45.4656 45.4144z"/><path d="M780.3904 554.5984c6.5024-9.2672 13.056-18.9952 19.6096-29.2864 56.8832-88.8832 48.896-160.4608 32.1536-204.8-21.9648-58.112-73.5232-106.3424-136.2944-128-47.8208-75.8272-182.4768-165.376-238.1824-142.6944-61.44 24.9856-65.8432 141.056-64.9728 190.0032a322.56 322.56 0 0 0-58.5728 66.56c-15.36 3.6864-41.984 10.752-69.5808 20.1216-67.7376 23.04-99.072 45.4656-104.7552 75.0592-11.8272 60.928 68.7104 158.72 173.7728 212.48 6.7072 10.5984 13.4656 20.48 20.48 29.0304-21.9136 21.1456-83.9168 92.16-55.552 194.304 21.3504 76.8 83.968 123.4944 168.0384 125.3376l7.424 8.448c2.304 2.6112 4.3008 5.12 5.7856 6.656a45.4656 45.4656 0 0 0 34.9184 16.3328h42.1888a45.568 45.568 0 0 0 45.5168-45.5168v-0.768h17.7152a45.6192 45.6192 0 0 0 45.5168 44.5952h42.7008a46.7968 46.7968 0 0 0 27.4944-9.728l1.024-0.7168c58.0096-39.424 93.2864-95.6928 102.0416-162.816 9.472-72.7552-14.3872-148.48-43.8272-190.464 3.0208-35.584-1.4336-58.7776-14.6432-74.1376zM220.16 415.2832c12.6464-14.08 76.3392-37.8368 139.6736-51.7632a30.72 30.72 0 0 0 19.6096-14.0288c8.0384-13.2608 34.7136-51.2 61.44-68.6592a30.72 30.72 0 0 0 13.9264-27.392c-5.12-89.3952 15.36-138.8032 25.1392-145.92 26.8288 0 135.8848 62.976 168.1408 124.7744a30.72 30.72 0 0 0 18.7392 15.36c39.1168 11.264 88.0128 42.0352 107.8784 94.72 16.896 44.7488 7.7312 96.6656-26.4192 150.0672-92.16 144.3328-174.848 169.9328-206.0288 174.08-34.048 4.7616-97.28 13.4656-160.768-91.904a30.72 30.72 0 0 0-12.8512-11.8784c-99.9936-48.4352-148.1216-126.1056-148.48-147.456z m137.5744 405.6576c-19.7632-71.2704 25.6-120.9856 38.9632-133.6832 1.792 1.4848 3.584 2.9184 5.12 4.2496-28.928 80.6912-11.2128 149.1968 14.9504 199.68-29.3888-12.6464-49.8688-36.5056-59.2384-70.2464z m420.2496-9.728c-6.5024 49.8688-31.8464 90.1632-75.4176 119.7568h-20.992a45.5168 45.5168 0 0 0-45.3632-44.5952h-49.8688a45.4656 45.4656 0 0 0-45.3632 45.4144v0.8704h-18.9952a39.424 39.424 0 0 0-1.9968-2.2528c-44.3904-50.176-91.0848-118.9376-63.0784-209.3056a177.9712 177.9712 0 0 0 57.1904 9.216 260.7616 260.7616 0 0 0 36.7616-2.9696c38.5024-5.12 106.8032-28.1088 183.6544-114.3808 0 5.4784-0.6656 12.288-1.7408 20.8384a30.72 30.72 0 0 0 7.2704 24.064c20.1216 23.3472 46.1824 88.2176 37.7344 153.344z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 5l0 14" />
<path d="M5 12l14 0" />
</svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-sparkles" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-terminal-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M8 9l3 3l-3 3" />
<path d="M13 15l3 0" />
<path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" />
</svg>

After

Width:  |  Height:  |  Size: 443 B

1
public/ai/agents/zed.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Zed Industries</title><path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z"/></svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" />
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>

After

Width:  |  Height:  |  Size: 880 B

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