Compare commits

...

61 Commits

Author SHA1 Message Date
陈大猫
1a3560a19f Polish tmux session modal (#1412)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-11 18:07:11 +08:00
陈大猫
3b525300e0 Tighten host tree topbar UX (#1411) 2026-06-11 17:49:35 +08:00
陈大猫
08ff49d3f5 Avoid broadcasting remote image paste paths (#1410) 2026-06-11 17:10:13 +08:00
陈大猫
f5c4271a07 Polish AI settings integrations UI (#1409) 2026-06-11 17:07:11 +08:00
陈大猫
74d41b43b6 [codex] Support remote image clipboard paste (#1408)
* Support remote image clipboard paste

* Address remote image paste review findings
2026-06-11 17:01:36 +08:00
陈大猫
3408bba303 [codex] Add Cursor SDK agent support (#1399)
* feat(ai): add Cursor SDK agent support

* fix(ai): harden Cursor SDK support

* fix(ai): address Cursor SDK review findings

* fix(ai): refresh Cursor environment handling

* fix(ai): refresh Cursor discovery scans

* fix(ai): enable Cursor recheck without path

* Use official Cursor agent icon

* Clarify Cursor SDK setup requirements

* Split Cursor SDK setup status

* Simplify Cursor settings copy

* Improve Cursor API key error

* Add safe Cursor auth diagnostics

* Disable Cursor local sandbox by default

* Show Cursor MCP tool names in tool cards

* Add spacing inside tool call groups
2026-06-11 16:43:34 +08:00
陈大猫
5e00e998a8 Add vault drag reorder ordering (#1407) 2026-06-11 16:05:17 +08:00
陈大猫
3847f0cda0 Merge pull request #1406 from binaricat/codex/per-host-line-timestamps
Make terminal timestamps per-host
2026-06-11 15:26:09 +08:00
bincxz
1ebcd017bd Make terminal timestamps per-host 2026-06-11 15:25:34 +08:00
陈大猫
9013a7e312 fix terminal popup release behavior (#1403) 2026-06-11 14:48:52 +08:00
陈大猫
afefbd953f Add serial YMODEM send support (#1400) 2026-06-11 11:11:31 +08:00
陈大猫
535b141b23 Merge pull request #1322 from lengyuqu/codebuddy
[Codebuddy] sdk
2026-06-11 10:21:35 +08:00
bincxz
b21e44b65f fix(ai): address CodeBuddy PR review findings 2026-06-11 10:18:49 +08:00
陈大猫
b42be379e3 Merge pull request #1393 from binaricat/fix/host-tree-search-clear
fix(terminal): add clear button to host tree search field
2026-06-11 04:34:18 +08:00
bincxz
b2f0a3bea3 fix(terminal): add clear button to host tree search field
Show an X control beside the host sidebar search input so users can reset the filter without manually deleting text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:34:07 +08:00
陈大猫
df3745d185 Merge pull request #1392 from binaricat/feat/terminal-system-sidebar-button
feat(terminal): add statusbar button to open system sidebar
2026-06-11 04:31:27 +08:00
bincxz
f85bb3f9b2 feat(terminal): add statusbar button to open system sidebar
Expose a quick Activity icon on SSH Linux sessions so users can jump directly to the system manager side panel from the terminal header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:31:01 +08:00
陈大猫
566f3e3c32 Merge pull request #1391 from binaricat/fix/history-scope-tab-style
fix(terminal): align history scope tab styles with system manager panel
2026-06-11 04:26:17 +08:00
bincxz
58eb91fb23 fix(terminal): align history scope tab styles with system manager panel
Use the shared bg-muted selected state so history host/global tabs match the system manager tab styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:24:48 +08:00
陈大猫
36267717ac feat(terminal): add system manager side panel for processes, tmux, and Docker
Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
2026-06-11 04:19:21 +08:00
陈大猫
5e323f1f8f feat(terminal): add global command history tab in side panel (#1387)
Record commands as users type across sessions with dedup and AI-noise
filtering, and browse them alongside per-host remote history. Refine the
scope switcher UI and route fullscreen layout recovery through the terminal
backend hook. Closes #1253.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:14:30 +08:00
陈大猫
c0efc9d5c1 feat(ai): add quick messages with slash command picker (#691)
Closes #691
2026-06-11 03:11:39 +08:00
陈大猫
61188ab8e2 fix(ai): reset panel view when deleting active chat session (#1386)
Fixes #1382

Deleting the active AI chat session left panelViewByScope pointing at a removed session, causing the side panel UI to blank. Sync panel view cleanup with session deletion, stop in-flight streams on delete, and return to draft with the deleted session's agent preserved.
2026-06-10 23:22:19 +08:00
陈大猫
ae209d37c1 feat(terminal): remote command history side panel (#1385)
* feat(terminal): add remote command history side panel

Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes #1381.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): expand command detail inline below selected row

Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): filter Netcatty AI PTY commands from remote history

Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): tighten detail strip and add run action

Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): address review findings before merge

Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:19:31 +08:00
陈大猫
a5b0efba75 fix(hotkeys): toggle quick switcher with the same shortcut (#1384)
Allow the quick switch shortcut to close the panel when pressed again,
including while the search input is focused, so users are not limited to
clicking outside to dismiss it.

Closes #1355

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:00:13 +08:00
陈大猫
5adb64e40e fix(terminal): recover terminal fit after app background and fullscreen (#1383)
Restore resize when macOS App Nap or GPU eviction leaves xterm stale after
switching away, by refitting on visibility/focus/fullscreen and fixing the
ResizeObserver race with async xterm boot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:43:16 +08:00
陈大猫
41fea1028d Merge pull request #1380 from binaricat/fix/issue-1363-nerd-font-glyphs-on-startup
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
fix(terminal): refresh Nerd Font glyphs on cold start (fixes #1363)
2026-06-10 19:59:13 +08:00
bincxz
5a90a4331b fix(terminal): refresh Nerd Font glyphs after bundled fonts load
Cold-start local terminals on Linux could cache Powerline icon tofu when the
shell prompt arrived before Symbols Nerd Font Mono finished loading. Preload
the icon fallback at the active cell size and clear the xterm atlas so
already-drawn prompt glyphs re-rasterize (fixes #1363).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:55:46 +08:00
秋秋
881f3b1a34 feat(et): support server stats for EternalTerminal sessions (#1377)
* feat(et): support server stats for EternalTerminal sessions

- Generalize the Mosh stats companion into reusable connection helpers
- Open a companion SSH connection so the host-info bar works for ET sessions
- Fall back to execOnEtSession for jumped ET sessions without a direct connection
- Forward host-key and algorithm options to the ET backend for companion parity
- Close the ET stats companion on session close, cleanup, and PTY exit

* fix(et): harden stats exec host-key trust and cleanup

Enforce StrictHostKeyChecking=yes for background ET stats/distro probes
instead of accept-new, merge vault known_hosts for parity with ssh2
companions, and wrap companion connection teardown in try/catch.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:46:24 +08:00
陈大猫
8be5865b76 fix(terminal): isolate workspace pane font zoom from global settings (#1379)
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:38:39 +08:00
陈大猫
685d1cb41a Merge pull request #1378 from binaricat/fix/issue-1375-shell-only-tab-numbers
feat(shortcuts): add option to skip pinned tabs for number keys
2026-06-10 19:15:48 +08:00
bincxz
14fe1e3ecb feat(shortcuts): add option to skip pinned tabs for number keys
Fixes #1375 by letting Cmd/Ctrl+[1...9] target only work tabs when enabled, while keeping the existing Terminus-style default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:15:15 +08:00
陈大猫
636f4d7037 fix(terminal): reflow workspace when compose bar or side panel layout changes (#1376)
Keep the compose bar inside the terminal workspace so SFTP side panels stay full height, refit xterm when the bar toggles, and remeasure split-pane geometry when side panels open or close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:00:46 +08:00
lengyuqu
c92ad2f601 fix: resolve merge artifacts - duplicate div tag and undefined deps 2026-06-10 16:01:35 +08:00
lengyuqu
602ca92476 merge: sync fork with upstream/main (v1.1.31+) 2026-06-10 15:42:24 +08:00
lengyuqu
3203ed7a19 merge: sync fork with upstream/main (latest) 2026-06-09 13:35:13 +08:00
lengyuqu
846d8246a3 Merge branch 'binaricat:main' into codebuddy 2026-06-08 23:44:08 +08:00
lengyuqu
26a04b22d3 merge: sync fork with upstream/main 2026-06-08 21:44:19 +08:00
lengyuqu
f5f55ffc2e 完整修复功能 2026-06-08 21:35:44 +08:00
lengyuqu
0792ce1415 codebuddy sdk 接入 2026-06-08 20:10:27 +08:00
lengyuqu
eca23a2691 Merge remote-tracking branch 'origin/codebuddy' into codebuddy 2026-06-08 19:02:54 +08:00
lengyuqu
aa1781577b 修复检查无 cli 的问题 2026-06-04 20:57:23 +08:00
lengyuqu
409d293faa 修复缺陷 2026-06-04 20:43:18 +08:00
lengyuqu
39fea86f13 过滤一些工具文件 2026-06-04 18:51:00 +08:00
lengyuqu
ce5d1d0e5a 文件遗漏 2026-06-04 18:49:07 +08:00
lengyuqu
7ac29366ae 修正 codebuddy认证服务 2026-06-04 18:48:34 +08:00
lengyuqu
4860581525 Merge branch 'binaricat:main' into codebuddy 2026-06-04 16:17:35 +08:00
lengyuqu
d9156349e1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:41:08 +08:00
lengyuqu
983b0b2f1d 修复 CodeBuddy 未检测时无法预配置 API Key 的问题
当 discovered_codebuddy 条目不存在时,updateCodebuddyEnv 现在会创建
一个 disabled 的托管条目,允许用户在安装 CLI 前预配置认证信息。
2026-06-01 21:28:44 +08:00
lengyuqu
a552c14cbd Delete .qoder directory 2026-06-01 21:06:26 +08:00
lengyuqu
3f5787ceb1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:05:51 +08:00
lengyuqu
e4ec2363d0 md 2026-06-01 21:03:53 +08:00
lengyuqu
84b71910ee 修复 CodeBuddy CLI 集成缺陷,对齐 Claude/Codex 标准
- 修复 ALLOWED_AGENT_COMMANDS 缺少 codebuddy(非 ACP 回退路径)
- 添加 CODEBUDDY_API_KEY 预检(list-models + stream handler)
- 添加 codebuddyAuthPresence 认证状态检测
- 错误时强制清理 provider 进程(避免僵尸进程)
- 认证失败时显示友好错误提示(引导用户配置 API Key)
2026-06-01 20:55:35 +08:00
lengyuqu
371217832b 移除 CodeBuddy auto 模型预设,新增 tool call 分组折叠
- 移除 CODEBUDDY_MODEL_PRESETS 常量及 getAgentModelPresets 中的 codebuddy 分支(auto 模型实际不可用)
- 新增 ToolCallGroup 组件,支持 Codex 风格可折叠 tool call 分组
- ChatMessageList 将连续 tool 消息、底部 pending calls、内嵌未解析 calls 分组展示
- 流式输出时自动展开,完成后自动折叠,支持手动切换
2026-06-01 20:22:08 +08:00
lengyuqu
afb514b472 md 2026-06-01 18:24:41 +08:00
lengyuqu
e14dc22bba 修正 bug 2026-06-01 18:14:48 +08:00
lengyuqu
6b7c12c23c Merge branch 'binaricat:main' into codebuddy 2026-06-01 16:40:44 +08:00
lengyuqu
222b3869dd 修复 codebuddy 的 acp 路径问题 2026-05-29 15:35:19 +08:00
lengyuqu
56af2d3840 Merge branch 'binaricat:main' into codebuddy 2026-05-29 15:32:07 +08:00
lengyuqu
1695470089 修复 codebuddy cli 2026-05-28 23:50:51 +08:00
lengyuqu
d4b5f799cb 添加 codebuddy cli 2026-05-28 23:50:15 +08:00
437 changed files with 24538 additions and 954 deletions

10
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
@@ -41,6 +42,15 @@ coverage
# Codex
/.codex/
# Qoder
.qoder
# Workbuddy
.workbuddy
# Codebuddy
.codebuddy
# AI / Superpowers generated docs (local only)
/docs/superpowers/

View File

@@ -229,6 +229,8 @@ function App({ settings }: { settings: SettingsState }) {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
@@ -725,7 +727,7 @@ function App({ settings }: { settings: SettingsState }) {
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
const handleWindowCommandCloseRequest = useCallback(async () => {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
@@ -985,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}

View File

@@ -1,10 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
class FakeInputHTMLElement {
tagName = 'INPUT';
isContentEditable = false;
closest(): FakeInputHTMLElement | null {
return null;
}
}
class FakeHTMLElement {
tagName = 'TEXTAREA';
isContentEditable = false;
@@ -68,3 +77,95 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('global hotkey handler routes quick switch through focused search inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['quickSwitch']);
});
test('quick switch hotkey toggles the quick switcher open state', () => {
let isQuickSwitcherOpen = false;
const setIsQuickSwitcherOpen = (next: boolean) => {
isQuickSwitcherOpen = next;
};
const noop = () => {};
const baseCtx = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
workspaces: [],
};
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent;
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, true);
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});

View File

@@ -4,6 +4,7 @@ import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
@@ -131,7 +132,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
return;
}
@@ -435,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
{
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
@@ -444,13 +449,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
const numberShortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
orderedTabs,
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
if (num <= numberShortcutTabs.length) {
setActiveTabId(numberShortcutTabs[num - 1]);
}
}
break;
@@ -553,6 +564,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
break;
case 'quickSwitch':
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
break;
case 'commandPalette':
setIsQuickSwitcherOpen(true);
break;

View File

@@ -14,7 +14,6 @@ Object.defineProperty(globalThis, 'localStorage', {
const {
getAppHostTreeLayerStyle,
shouldAutoOpenHostTreeOnSurfaceChange,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
@@ -34,28 +33,9 @@ test('shared host tree layer is hidden behind root pages', () => {
});
});
test('shared host tree auto-opens when entering a work tab surface', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: false,
surfaceVisible: true,
}), true);
});
test('shared host tree does not force reopen while already on work tab surfaces', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: true,
surfaceVisible: true,
}), false);
});
test('shared host tree does not auto-open when disabled', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: false,
previousSurfaceVisible: false,
surfaceVisible: true,
}), false);
test('shared host tree does not force open when entering a work tab surface', () => {
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {

View File

@@ -1,12 +1,10 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { scheduleAfterInstantThemeSwitch } from '../state/useActiveChromeTheme';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
@@ -16,6 +14,7 @@ interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
@@ -34,22 +33,11 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
};
}
export function shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
}: {
enabled: boolean;
previousSurfaceVisible: boolean;
surfaceVisible: boolean;
}): boolean {
return enabled && surfaceVisible && !previousSurfaceVisible;
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
groupConfigs,
sessions,
workspaces,
editorTabs,
@@ -60,8 +48,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const previousSurfaceVisibleRef = useRef(false);
const cancelAutoOpenRef = useRef<(() => void) | null>(null);
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
@@ -73,28 +59,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
sessionIds,
workspaceIds,
});
useEffect(() => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
const previousSurfaceVisible = previousSurfaceVisibleRef.current;
previousSurfaceVisibleRef.current = surfaceVisible;
if (shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
})) {
cancelAutoOpenRef.current = scheduleAfterInstantThemeSwitch(() => {
cancelAutoOpenRef.current = null;
terminalHostTreeStore.setIsOpen(true);
});
}
return () => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
};
}, [enabled, surfaceVisible]);
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
@@ -114,6 +78,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={resolvedPreviewTheme}
activeHostId={activeHostId}
onConnect={onConnect}

View File

@@ -45,7 +45,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
@@ -146,6 +146,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
enabled={settings.showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
@@ -254,6 +255,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateSessionFontSize={updateSessionFontSize}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
@@ -263,6 +266,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
shellHistory={shellHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -280,6 +284,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});

View File

@@ -0,0 +1,14 @@
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...pinnedTabs, ...workTabs];
}

View File

@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
const interpolate = (template: string, values?: InterpolationValues): string => {
if (!values) return template;
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const replaceDoubleBraceToken = (match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return match;
return String(v);
};
const replaceSingleBraceToken = (_match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return '';
return String(v);
});
};
return template
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {

View File

@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
import { enVaultMessages } from './en/vault';
import { enTerminalMessages } from './en/terminal';
import { enAiMessages } from './en/ai';
import { enSystemManagerMessages } from './en/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const en: Messages = {
...enVaultMessages,
...enTerminalMessages,
...enAiMessages,
...enSystemManagerMessages,
};
export default en;

View File

@@ -106,6 +106,54 @@ export const enAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Uses the Cursor SDK.',
'ai.cursor.detecting': 'Detecting...',
'ai.cursor.detected': 'Available',
'ai.cursor.notFound': 'Unavailable',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Detected',
'ai.cursor.notInstalled': 'Not detected',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': 'Configured',
'ai.cursor.apiKeyMissing': 'Missing',
'ai.cursor.apiKeyFromEnv': 'From environment',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Save',
'ai.cursor.saved': 'Saved',
'ai.cursor.showApiKey': 'Show API key',
'ai.cursor.hideApiKey': 'Hide API key',
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
'ai.cursor.check': 'Check',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
'ai.codebuddy.detecting': 'Detecting...',
'ai.codebuddy.detected': 'Detected',
'ai.codebuddy.notFound': 'Not found',
'ai.codebuddy.path': 'Path:',
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Check',
'ai.codebuddy.configSection': 'Authentication & config (optional)',
'ai.codebuddy.internetEnv': 'Internet Environment',
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -127,6 +175,29 @@ export const enAiMessages: Messages = {
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Quick Messages
'ai.quickMessages.title': 'Quick Messages',
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
'ai.quickMessages.add': 'Add Quick Message',
'ai.quickMessages.createTitle': 'New Quick Message',
'ai.quickMessages.editTitle': 'Edit Quick Message',
'ai.quickMessages.name': 'Name',
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
'ai.quickMessages.slug': 'Command',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Description (optional)',
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
'ai.quickMessages.content': 'Message content',
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
'ai.quickMessages.error.nameRequired': 'Name is required.',
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
'ai.quickMessages.error.contentRequired': 'Message content is required.',
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
// 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.',
@@ -175,6 +246,7 @@ export const enAiMessages: Messages = {
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
@@ -185,6 +257,13 @@ export const enAiMessages: Messages = {
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
'ai.chat.slashQuickMessages': 'Quick messages',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Slash commands',
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// 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.',
@@ -228,6 +307,7 @@ export const enAiMessages: Messages = {
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'History',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',

View File

@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.manual': 'Manual order',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
@@ -266,9 +267,9 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -431,6 +432,15 @@ export const enCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
@@ -442,8 +452,6 @@ export const enCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
@@ -468,6 +476,8 @@ export const enCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',

View File

@@ -0,0 +1,175 @@
import type { Messages } from '../types';
export const enSystemManagerMessages: Messages = {
'terminal.layer.system': 'System',
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
'systemManager.errors.loadProcesses': 'Failed to load processes',
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
'systemManager.errors.actionFailed': 'Action failed',
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
'systemManager.processes.term': 'Terminate',
'systemManager.processes.kill': 'Kill',
'systemManager.processes.stop': 'Stop (SIGSTOP)',
'systemManager.processes.cont': 'Continue (SIGCONT)',
'systemManager.processes.hup': 'Hang up (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
'systemManager.processes.filter.all': 'All',
'systemManager.processes.filter.running': 'Running',
'systemManager.processes.ppid': 'Parent PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Virtual size',
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Command',
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',
'systemManager.tmux.newSessionTitle': 'New tmux session',
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
'systemManager.tmux.newSessionTabCustom': 'Custom command',
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
'systemManager.tmux.pickSnippet': 'From snippets',
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Session name',
'systemManager.tmux.newSessionCommand': 'Start command',
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
'systemManager.tmux.creating': 'Creating…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
'systemManager.tmux.empty': 'No tmux sessions',
'systemManager.tmux.attach': 'Attach',
'systemManager.tmux.attached': 'Attached',
'systemManager.tmux.detached': 'Detached',
'systemManager.tmux.windows': '{{count}} window(s)',
'systemManager.tmux.created': 'Created',
'systemManager.tmux.activity': 'Activity',
'systemManager.tmux.rename': 'Rename',
'systemManager.tmux.detach': 'Detach all',
'systemManager.tmux.killSession': 'Kill session',
'systemManager.tmux.killServer': 'Kill server',
'systemManager.tmux.loadingDetails': 'Loading details…',
'systemManager.tmux.clients': 'Attached clients',
'systemManager.tmux.windowList': 'Windows',
'systemManager.tmux.newWindow': 'New window',
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes',
'systemManager.tmux.panes': '{{count}} pane(s)',
'systemManager.tmux.active': 'active',
'systemManager.tmux.unnamedWindow': 'Unnamed window',
'systemManager.tmux.unnamedPane': 'Unnamed pane',
'systemManager.tmux.attachWindow': 'Attach to window',
'systemManager.tmux.selectWindow': 'Select window',
'systemManager.tmux.killWindow': 'Kill window',
'systemManager.tmux.killPane': 'Kill pane',
'systemManager.tmux.splitHorizontal': 'Split horizontal',
'systemManager.tmux.splitVertical': 'Split vertical',
'systemManager.tmux.sendKeys': 'Send keys',
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
'systemManager.tmux.renameSessionPrompt': 'Rename session',
'systemManager.tmux.renameWindowPrompt': 'Rename window',
'systemManager.tmux.windowName': 'Window name',
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
'systemManager.tmux.meta': '{{count}} session(s)',
'systemManager.docker.title': 'Containers',
'systemManager.docker.subTabs.containers': 'Containers',
'systemManager.docker.subTabs.images': 'Images',
'systemManager.docker.empty': 'No containers found',
'systemManager.docker.imagesEmpty': 'No images found',
'systemManager.docker.search': 'Search containers…',
'systemManager.docker.searchImages': 'Search images…',
'systemManager.docker.filter.all': 'All',
'systemManager.docker.filter.running': 'Running',
'systemManager.docker.filter.stopped': 'Stopped',
'systemManager.docker.filter.paused': 'Paused',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Logs',
'systemManager.docker.details': 'Details',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Image inspect',
'systemManager.docker.confirmRemove': 'Remove this container?',
'systemManager.docker.confirmKill': 'Force kill this container?',
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
'systemManager.docker.confirmPrune': 'Remove dangling images?',
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
'systemManager.docker.pause': 'Pause',
'systemManager.docker.unpause': 'Unpause',
'systemManager.docker.restart': 'Restart',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Container name',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Repository name',
'systemManager.docker.tagNamePrompt': 'Tag name',
'systemManager.docker.meta': '{{count}} container(s)',
'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
'systemManager.inspect.started': 'Started',
'systemManager.inspect.restartPolicy': 'Restart policy',
'systemManager.inspect.command': 'Command',
'systemManager.inspect.ports': 'Ports',
'systemManager.inspect.networks': 'Networks',
'systemManager.inspect.mounts': 'Mounts',
'systemManager.inspect.env': 'Environment',
'systemManager.inspect.labels': 'Labels',
'systemManager.inspect.tags': 'Tags',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Size',
'systemManager.inspect.platform': 'Platform',
'systemManager.inspect.workdir': 'Working dir',
'systemManager.inspect.exposedPorts': 'Exposed ports',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Hide JSON',
};

View File

@@ -5,9 +5,26 @@ export const enTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Command history',
'history.scope.label': 'History scope',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Search history...',
'history.loading': 'Loading remote history...',
'history.meta.count': '{count} commands',
'history.empty.noSession': 'Open a remote session to view its command history.',
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
'history.empty.noHistory': 'No command history found on this host.',
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
'history.action.refresh': 'Refresh',
'history.action.retry': 'Retry',
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -83,10 +100,17 @@ export const enTerminalMessages: Messages = {
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
'terminal.auth.password': 'Password',

View File

@@ -530,6 +530,8 @@ export const enVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',

View File

@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
import { ruTerminalMessages } from './ru/terminal';
import { ruAiMessages } from './ru/ai';
import { ruSystemManagerMessages } from './ru/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const ru: Messages = {
...ruVaultMessages,
...ruTerminalMessages,
...ruAiMessages,
...ruSystemManagerMessages,
};
export default ru;

View File

@@ -106,6 +106,54 @@ export const ruAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
'ai.copilot.check': 'Проверить',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Использует Cursor SDK.',
'ai.cursor.detecting': 'Обнаружение...',
'ai.cursor.detected': 'Доступен',
'ai.cursor.notFound': 'Недоступен',
'ai.cursor.path': 'Среда:',
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Обнаружено',
'ai.cursor.notInstalled': 'Не обнаружено',
'ai.cursor.apiKeyStatus': 'API-ключ',
'ai.cursor.apiKeyConfigured': 'Настроен',
'ai.cursor.apiKeyMissing': 'Не указан',
'ai.cursor.apiKeyFromEnv': 'Из окружения',
'ai.cursor.apiKey': 'API-ключ',
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Сохранить',
'ai.cursor.saved': 'Сохранено',
'ai.cursor.showApiKey': 'Показать API-ключ',
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
'ai.cursor.check': 'Проверить',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.codebuddy.detecting': 'Обнаружение...',
'ai.codebuddy.detected': 'Обнаружен',
'ai.codebuddy.notFound': 'Не найден',
'ai.codebuddy.path': 'Путь:',
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Проверить',
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
'ai.codebuddy.internetEnv': 'Сетевая среда',
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
@@ -127,6 +175,29 @@ export const ruAiMessages: Messages = {
'ai.userSkills.status.ready': 'Готово',
'ai.userSkills.status.warning': 'Предупреждение',
// AI Quick Messages
'ai.quickMessages.title': 'Быстрые сообщения',
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
'ai.quickMessages.add': 'Добавить быстрое сообщение',
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
'ai.quickMessages.name': 'Название',
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
'ai.quickMessages.slug': 'Команда',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
'ai.quickMessages.content': 'Текст сообщения',
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
'ai.quickMessages.error.nameRequired': 'Укажите название.',
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
// AI Chat
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
@@ -175,6 +246,7 @@ export const ruAiMessages: Messages = {
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
@@ -185,6 +257,13 @@ export const ruAiMessages: Messages = {
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuUserSkills': 'Пользовательские skills',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Команды /',
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
@@ -228,6 +307,7 @@ export const ruAiMessages: Messages = {
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
'terminal.layer.history': 'История',
'terminal.layer.theme': 'Тема',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',

View File

@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
'common.more': 'Ещё',
'common.selectAHost': 'Выберите хост',
'common.selectAHostPlaceholder': 'Выберите хост...',
'sort.manual': 'Ручной порядок',
'sort.az': 'А-Я',
'sort.za': 'Я-А',
'sort.newest': 'Сначала новые',
@@ -266,9 +267,9 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
@@ -431,6 +432,15 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
@@ -442,8 +452,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
@@ -468,6 +476,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',

View File

@@ -0,0 +1,175 @@
import type { Messages } from '../types';
export const ruSystemManagerMessages: Messages = {
'terminal.layer.system': 'Система',
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
'systemManager.processes.term': 'Завершить',
'systemManager.processes.kill': 'Убить',
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
'systemManager.processes.hup': 'Сигнал SIGHUP',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
'systemManager.processes.filter.all': 'Все',
'systemManager.processes.filter.running': 'Активные',
'systemManager.processes.ppid': 'Родительский PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Виртуальный размер',
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
'systemManager.processes.state.zombie': 'Зомби',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'Память',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Команда',
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
'systemManager.tmux.pickSnippet': 'Из сниппетов',
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
'systemManager.tmux.newSessionName': 'Имя сессии',
'systemManager.tmux.newSessionCommand': 'Команда запуска',
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
'systemManager.tmux.creating': 'Создание…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
'systemManager.tmux.empty': 'Нет сессий tmux',
'systemManager.tmux.attach': 'Подключить',
'systemManager.tmux.attached': 'Подключена',
'systemManager.tmux.detached': 'Отключена',
'systemManager.tmux.windows': '{{count}} окон',
'systemManager.tmux.created': 'Создана',
'systemManager.tmux.activity': 'Активность',
'systemManager.tmux.rename': 'Переименовать',
'systemManager.tmux.detach': 'Отключить всех',
'systemManager.tmux.killSession': 'Завершить сессию',
'systemManager.tmux.killServer': 'Остановить сервер',
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
'systemManager.tmux.clients': 'Подключённые клиенты',
'systemManager.tmux.windowList': 'Окна',
'systemManager.tmux.newWindow': 'Новое окно',
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
'systemManager.tmux.panes': '{{count}} пан.',
'systemManager.tmux.active': 'активно',
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
'systemManager.tmux.unnamedPane': 'Безымянная панель',
'systemManager.tmux.attachWindow': 'Подключить к окну',
'systemManager.tmux.selectWindow': 'Выбрать окно',
'systemManager.tmux.killWindow': 'Закрыть окно',
'systemManager.tmux.killPane': 'Закрыть панель',
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
'systemManager.tmux.splitVertical': 'Разделить вертикально',
'systemManager.tmux.sendKeys': 'Отправить клавиши',
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
'systemManager.tmux.windowName': 'Имя окна',
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
'systemManager.tmux.meta': '{{count}} сессий',
'systemManager.docker.title': 'Контейнеры',
'systemManager.docker.subTabs.containers': 'Контейнеры',
'systemManager.docker.subTabs.images': 'Образы',
'systemManager.docker.empty': 'Контейнеры не найдены',
'systemManager.docker.imagesEmpty': 'Образы не найдены',
'systemManager.docker.search': 'Поиск контейнеров…',
'systemManager.docker.searchImages': 'Поиск образов…',
'systemManager.docker.filter.all': 'Все',
'systemManager.docker.filter.running': 'Запущены',
'systemManager.docker.filter.stopped': 'Остановлены',
'systemManager.docker.filter.paused': 'На паузе',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Логи',
'systemManager.docker.details': 'Детали',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Inspect образа',
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
'systemManager.docker.pause': 'Пауза',
'systemManager.docker.unpause': 'Возобновить',
'systemManager.docker.restart': 'Перезапустить',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Имя контейнера',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
'systemManager.docker.tagNamePrompt': 'Имя тега',
'systemManager.docker.meta': '{{count}} конт.',
'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
'systemManager.inspect.started': 'Запущен',
'systemManager.inspect.restartPolicy': 'Перезапуск',
'systemManager.inspect.command': 'Команда',
'systemManager.inspect.ports': 'Порты',
'systemManager.inspect.networks': 'Сети',
'systemManager.inspect.mounts': 'Тома',
'systemManager.inspect.env': 'Окружение',
'systemManager.inspect.labels': 'Метки',
'systemManager.inspect.tags': 'Теги',
'systemManager.inspect.digests': 'Дайджесты',
'systemManager.inspect.size': 'Размер',
'systemManager.inspect.platform': 'Платформа',
'systemManager.inspect.workdir': 'Рабочий каталог',
'systemManager.inspect.exposedPorts': 'Открытые порты',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Скрыть JSON',
};

View File

@@ -26,9 +26,26 @@ export const ruTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.history': 'История команд',
'history.scope.label': 'Область истории',
'history.tab.host': 'Хост',
'history.tab.global': 'Глобальная',
'history.searchPlaceholder': 'Поиск по истории...',
'history.loading': 'Загрузка удалённой истории...',
'history.meta.count': '{count} команд',
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
'history.action.refresh': 'Обновить',
'history.action.retry': 'Повторить',
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -104,10 +121,17 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',

View File

@@ -562,6 +562,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',

View File

@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
import { zhCNVaultMessages } from './zh-CN/vault';
import { zhCNTerminalMessages } from './zh-CN/terminal';
import { zhCNAiMessages } from './zh-CN/ai';
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const zhCN: Messages = {
...zhCNVaultMessages,
...zhCNTerminalMessages,
...zhCNAiMessages,
...zhCnSystemManagerMessages,
};
export default zhCN;

View File

@@ -106,6 +106,54 @@ export const zhCNAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '检测中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '运行环境:',
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已检测到',
'ai.cursor.notInstalled': '未检测到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填写',
'ai.cursor.apiKeyMissing': '未填写',
'ai.cursor.apiKeyFromEnv': '来自环境变量',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY填写后会覆盖',
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用填写保存后会覆盖它。',
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key清空保存后会回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '保存',
'ai.cursor.saved': '已保存',
'ai.cursor.showApiKey': '显示 API Key',
'ai.cursor.hideApiKey': '隐藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '检查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '通过官方 Agent SDK`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
'ai.codebuddy.detecting': '检测中...',
'ai.codebuddy.detected': '已检测到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路径:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '检查',
'ai.codebuddy.configSection': '认证与配置(可选)',
'ai.codebuddy.internetEnv': '网络环境',
'ai.codebuddy.internetEnv.default': '默认(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
@@ -127,6 +175,29 @@ export const zhCNAiMessages: Messages = {
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Quick Messages
'ai.quickMessages.title': '快捷消息',
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
'ai.quickMessages.add': '添加快捷消息',
'ai.quickMessages.createTitle': '新建快捷消息',
'ai.quickMessages.editTitle': '编辑快捷消息',
'ai.quickMessages.name': '名称',
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
'ai.quickMessages.slug': '命令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '说明(可选)',
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
'ai.quickMessages.content': '消息内容',
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
'ai.quickMessages.error.nameRequired': '请填写名称。',
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
@@ -175,6 +246,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
@@ -185,6 +257,13 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
'ai.chat.slashQuickMessages': '快捷消息',
'ai.chat.slashUserSkills': '用户 Skills',
'ai.chat.quickMessages': '快捷命令',
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -228,6 +307,7 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.history': '命令历史',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',

View File

@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.manual': '手动顺序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
@@ -250,9 +251,9 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',

View File

@@ -0,0 +1,175 @@
import type { Messages } from '../types';
export const zhCnSystemManagerMessages: Messages = {
'terminal.layer.system': '系统',
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
'systemManager.errors.loadProcesses': '加载进程列表失败',
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
'systemManager.errors.actionFailed': '操作失败',
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
'systemManager.processes.term': '终止',
'systemManager.processes.kill': '强杀',
'systemManager.processes.stop': '暂停 (SIGSTOP)',
'systemManager.processes.cont': '继续 (SIGCONT)',
'systemManager.processes.hup': '挂断 (SIGHUP)',
'systemManager.processes.renice': '调整优先级',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL',
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '运行中',
'systemManager.processes.ppid': '父进程 PID',
'systemManager.processes.rss': '物理内存',
'systemManager.processes.vsz': '虚拟内存',
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
'systemManager.processes.state.zombie': '僵尸',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': '内存',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': '命令',
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
'systemManager.tmux.newSessionTabCustom': '自定义命令',
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
'systemManager.tmux.pickSnippet': '从代码片段选择',
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
'systemManager.tmux.newSessionName': '会话名称',
'systemManager.tmux.newSessionCommand': '启动命令',
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev可选',
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
'systemManager.tmux.creating': '创建中…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
'systemManager.tmux.empty': '没有 tmux 会话',
'systemManager.tmux.attach': '附加',
'systemManager.tmux.attached': '已附加',
'systemManager.tmux.detached': '未附加',
'systemManager.tmux.windows': '{{count}} 个窗口',
'systemManager.tmux.created': '创建时间',
'systemManager.tmux.activity': '活动时间',
'systemManager.tmux.rename': '重命名',
'systemManager.tmux.detach': '全部分离',
'systemManager.tmux.killSession': '结束会话',
'systemManager.tmux.killServer': '结束 tmux 服务',
'systemManager.tmux.loadingDetails': '正在加载详情…',
'systemManager.tmux.clients': '已附加客户端',
'systemManager.tmux.windowList': '窗口',
'systemManager.tmux.newWindow': '新建窗口',
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
'systemManager.tmux.panes': '{{count}} 个面板',
'systemManager.tmux.active': '当前',
'systemManager.tmux.unnamedWindow': '未命名窗口',
'systemManager.tmux.unnamedPane': '未命名面板',
'systemManager.tmux.attachWindow': '附加到窗口',
'systemManager.tmux.selectWindow': '选中窗口',
'systemManager.tmux.killWindow': '关闭窗口',
'systemManager.tmux.killPane': '关闭面板',
'systemManager.tmux.splitHorizontal': '水平分屏',
'systemManager.tmux.splitVertical': '垂直分屏',
'systemManager.tmux.sendKeys': '发送按键',
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
'systemManager.tmux.renameSessionPrompt': '重命名会话',
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
'systemManager.tmux.windowName': '窗口名称',
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}',
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
'systemManager.tmux.meta': '{{count}} 个会话',
'systemManager.docker.title': '容器',
'systemManager.docker.subTabs.containers': '容器',
'systemManager.docker.subTabs.images': '镜像',
'systemManager.docker.empty': '未找到容器',
'systemManager.docker.imagesEmpty': '未找到镜像',
'systemManager.docker.search': '搜索容器…',
'systemManager.docker.searchImages': '搜索镜像…',
'systemManager.docker.filter.all': '全部',
'systemManager.docker.filter.running': '运行中',
'systemManager.docker.filter.stopped': '已停止',
'systemManager.docker.filter.paused': '已暂停',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': '日志',
'systemManager.docker.details': '详情',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': '镜像 Inspect',
'systemManager.docker.confirmRemove': '确定删除此容器?',
'systemManager.docker.confirmKill': '确定强制终止此容器?',
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
'systemManager.docker.pause': '暂停',
'systemManager.docker.unpause': '恢复',
'systemManager.docker.restart': '重启',
'systemManager.docker.kill': '强杀',
'systemManager.docker.renamePrompt': '容器名称',
'systemManager.docker.prune': '清理悬空',
'systemManager.docker.pruneAll': '清理全部',
'systemManager.docker.tag': '打标签',
'systemManager.docker.tagRepoPrompt': '仓库名',
'systemManager.docker.tagNamePrompt': '标签名',
'systemManager.docker.meta': '{{count}} 个容器',
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
'systemManager.inspect.started': '启动时间',
'systemManager.inspect.restartPolicy': '重启策略',
'systemManager.inspect.command': '启动命令',
'systemManager.inspect.ports': '端口映射',
'systemManager.inspect.networks': '网络',
'systemManager.inspect.mounts': '挂载',
'systemManager.inspect.env': '环境变量',
'systemManager.inspect.labels': '标签',
'systemManager.inspect.tags': '镜像标签',
'systemManager.inspect.digests': '摘要',
'systemManager.inspect.size': '大小',
'systemManager.inspect.platform': '平台',
'systemManager.inspect.workdir': '工作目录',
'systemManager.inspect.exposedPorts': '暴露端口',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': '收起 JSON',
};

View File

@@ -5,6 +5,22 @@ export const zhCNTerminalMessages: Messages = {
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// Command history side panel
'history.scope.label': '历史范围',
'history.tab.host': '主机',
'history.tab.global': '全局',
'history.searchPlaceholder': '搜索历史命令...',
'history.loading': '正在读取远程历史...',
'history.meta.count': '{count} 条',
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
'history.empty.noHistory': '该主机上未找到命令历史。',
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
'history.action.refresh': '刷新',
'history.action.retry': '重试',
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -286,14 +302,21 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
'settings.terminal.section.systemManager': '系统管理',
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
@@ -311,6 +334,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',

View File

@@ -113,6 +113,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.sshAlgorithms': 'SSH 算法',
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '给输出加时间戳',
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
@@ -213,9 +215,11 @@ export const zhCNVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
'terminal.toolbar.history': '命令历史',
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
@@ -291,10 +295,17 @@ export const zhCNVaultMessages: Messages = {
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.ymodem.selectFile': '选择要发送的文件',
'terminal.ymodem.allFiles': '所有文件',
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
'terminal.auth.password': '密码',

View File

@@ -10,6 +10,7 @@ import {
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneStaleSessionPanelViews,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
});
});
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "deleted-session" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.deepEqual(next, {
"terminal:1": { mode: "draft" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
});
});
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-keep" },
"terminal:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.equal(next, panelViewByScope);
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),

View File

@@ -115,6 +115,25 @@ export function setSessionView(
};
}
export function pruneStaleSessionPanelViews(
panelViewByScope: PanelViewByScope,
validSessionIds: Set<string>,
): PanelViewByScope {
let next = panelViewByScope;
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
continue;
}
const updated = setDraftView(next, scopeKey);
if (updated !== next) {
next = updated;
}
}
return next;
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,

View File

@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
import {
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
});

View File

@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
return evt.reason === "exited" && evt.exitCode === 0;
}

View File

@@ -0,0 +1,62 @@
import type { SessionCapabilities } from '../../domain/systemManager/types';
type Listener = () => void;
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
const listenersBySessionId = new Map<string, Set<Listener>>();
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
export const sessionCapabilitiesStore = {
get(sessionId: string): SessionCapabilities | undefined {
return capabilitiesBySessionId.get(sessionId);
},
set(sessionId: string, capabilities: SessionCapabilities) {
const prev = capabilitiesBySessionId.get(sessionId);
if (
prev
&& prev.targetOs === capabilities.targetOs
&& prev.hasTmux === capabilities.hasTmux
&& prev.hasDocker === capabilities.hasDocker
&& prev.probedAt === capabilities.probedAt
) {
return;
}
capabilitiesBySessionId.set(sessionId, capabilities);
notifySession(sessionId);
},
delete(sessionId: string) {
if (!capabilitiesBySessionId.delete(sessionId)) return;
notifySession(sessionId);
listenersBySessionId.delete(sessionId);
},
/** Drop cached capabilities for sessions that no longer exist. */
prune(liveSessionIds: ReadonlySet<string>) {
for (const sessionId of capabilitiesBySessionId.keys()) {
if (!liveSessionIds.has(sessionId)) {
capabilitiesBySessionId.delete(sessionId);
listenersBySessionId.delete(sessionId);
}
}
},
subscribe(sessionId: string, listener: Listener): () => void {
let set = listenersBySessionId.get(sessionId);
if (!set) {
set = new Set();
listenersBySessionId.set(sessionId, set);
}
set.add(listener);
return () => {
set?.delete(listener);
if (set && set.size === 0) {
listenersBySessionId.delete(sessionId);
}
};
},
};

View File

@@ -64,6 +64,7 @@ export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;

View File

@@ -28,6 +28,7 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -77,6 +78,7 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -112,6 +114,7 @@ interface UseSettingsStorageSyncParams {
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -133,7 +136,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -142,7 +145,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -156,7 +159,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
@@ -166,7 +169,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
@@ -380,6 +383,12 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
setShellOnlyTabNumberShortcutsState(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';
@@ -448,6 +457,7 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,

View File

@@ -0,0 +1,16 @@
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export async function writeSystemManagerDiagnostic(
message: string,
extra?: Record<string, unknown>,
) {
try {
await netcattyBridge.get()?.logDiagnostic?.({
source: 'system-manager',
message,
extra,
});
} catch {
// Diagnostics must never block the user action being diagnosed.
}
}

View File

@@ -43,6 +43,8 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};

View File

@@ -17,7 +17,10 @@ import {
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIDraft,
AISession,
@@ -35,6 +38,8 @@ import {
activateDraftView,
clearScopeDraftState,
ensureDraftForScopeState,
pruneStaleSessionPanelViews,
setDraftView,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
@@ -158,6 +163,11 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
// ── Quick Messages (slash prompts) ──
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
@@ -187,12 +197,22 @@ export function useAIState() {
}
}
if (!changed) return;
if (changed) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setPanelViewByScopeRaw((prev) => {
const next = pruneStaleSessionPanelViews(prev, validSessionIds);
if (next === prev) {
return prev;
}
setLatestAIPanelViewByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
@@ -263,6 +283,16 @@ export function useAIState() {
}
}, []);
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
setQuickMessagesRaw((prev) => {
const nextRaw = typeof value === 'function' ? value(prev) : value;
const next = sanitizeQuickMessages(nextRaw);
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
return next;
});
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
@@ -454,6 +484,11 @@ export function useAIState() {
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
case STORAGE_KEY_AI_QUICK_MESSAGES: {
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
setQuickMessagesRaw(sanitizeQuickMessages(messages));
break;
}
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
@@ -593,6 +628,19 @@ export function useAIState() {
}
return prev;
});
setPanelViewByScopeRaw((prev) => {
const currentPanelView = prev[scopeKey];
if (currentPanelView?.mode !== 'session' || currentPanelView.sessionId !== sessionId) {
return prev;
}
const next = setDraftView(prev, scopeKey);
if (next === prev) {
return prev;
}
setLatestAIPanelViewByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}
}, [persistSessions]);
@@ -974,6 +1022,8 @@ export function useAIState() {
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
@@ -1029,6 +1079,8 @@ export function useAIState() {
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,

View File

@@ -3,7 +3,7 @@ import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
@@ -19,20 +19,27 @@ export function useAgentDiscovery(
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discover = useCallback(async () => {
const cursorApiKeyPresent = externalAgents.some(
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
);
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge) return;
setIsDiscovering(true);
try {
const agents = await bridge.aiDiscoverAgents();
const agents = await bridge.aiDiscoverAgents({
...discoverOptions,
apiKeyPresent: cursorApiKeyPresent,
});
setDiscoveredAgents(agents);
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
}
}, []);
}, [cursorApiKeyPresent]);
useEffect(() => {
if (!enabled) return;
@@ -128,7 +135,7 @@ export function useAgentDiscovery(
discoveredAgents,
unconfiguredAgents,
isDiscovering,
rediscover: discover,
rediscover: () => discover({ refreshShellEnv: true }),
enableAgent,
};
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -30,7 +31,7 @@ let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
export type SortMode = "manual" | "az" | "za" | "newest" | "oldest";
export interface UsePortForwardingStateResult {
rules: PortForwardingRule[];
@@ -52,6 +53,7 @@ export interface UsePortForwardingStateResult {
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
deleteRule: (id: string) => void;
duplicateRule: (id: string) => void;
reorderRule: (sourceId: string, targetId: string, position: VaultOrderPosition) => void;
importRules: (rules: PortForwardingRule[]) => void;
setRuleStatus: (
@@ -90,9 +92,9 @@ const notifyListeners = () => {
};
const setGlobalRules = (newRules: PortForwardingRule[]) => {
globalRules = newRules;
globalRules = normalizeVaultOrder(newRules);
notifyListeners();
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
@@ -136,7 +138,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
STORAGE_KEY_PF_VIEW_MODE,
"grid",
);
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [sortMode, setSortMode] = useState<SortMode>("manual");
const [search, setSearch] = useState("");
const [preferFormMode, setPreferFormModeState] = useState<boolean>(() => {
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
@@ -249,6 +251,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
id: crypto.randomUUID(),
createdAt: Date.now(),
status: "inactive",
order: getNextVaultOrder(globalRules),
};
const updated = [...globalRules, newRule];
setGlobalRules(updated);
@@ -294,6 +297,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
status: "inactive",
error: undefined,
lastUsedAt: undefined,
order: getNextVaultOrder(globalRules),
};
const updated = [...globalRules, copy];
setGlobalRules(updated);
@@ -302,6 +306,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
[],
);
const reorderRule = useCallback(
(sourceId: string, targetId: string, position: VaultOrderPosition) => {
setGlobalRules(reorderVaultItems(globalRules, sourceId, targetId, position));
setSortMode("manual");
},
[],
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
@@ -444,6 +456,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
case "oldest":
result.sort((a, b) => a.createdAt - b.createdAt);
break;
case "manual":
result = sortByVaultOrder(result);
break;
}
return result;
@@ -469,6 +484,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
updateRule,
deleteRule,
duplicateRule,
reorderRule,
importRules,
setRuleStatus,

View File

@@ -0,0 +1,174 @@
import { useCallback, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from '../../domain/remoteHistory';
import type { RemoteHistoryEntry } from '../../domain/models';
export interface RemoteHistoryHostState {
entries: RemoteHistoryEntry[];
loading: boolean;
error: string | null;
fetchedAt: number | null;
}
const EMPTY_STATE: RemoteHistoryHostState = {
entries: [],
loading: false,
error: null,
fetchedAt: null,
};
const PENDING_RETRY_MS = 1500;
const PENDING_MAX_RETRIES = 12;
export interface UseRemoteHistoryState {
getState: (
hostId: string | null | undefined,
sessionId?: string | null,
) => RemoteHistoryHostState;
fetch: (sessionId: string, hostId: string) => Promise<void>;
clear: (hostId: string, sessionId?: string | null) => void;
}
function cacheKey(hostId: string, sessionId: string): string {
return `${hostId}\0${sessionId}`;
}
/**
* Owns per-session remote shell history state. Fetches the remote host's shell
* history via the SSH bridge — which detects the login shell and returns only
* the matching file(s) — parses and de-dupes them, and keeps an in-memory
* cache keyed by (hostId, sessionId). The cache is intentionally not persisted
* — history files can contain sensitive content.
*/
export function useRemoteHistoryState(): UseRemoteHistoryState {
const [byKey, setByKey] = useState<Record<string, RemoteHistoryHostState>>({});
const requestIdByKey = useRef<Record<string, number>>({});
const getState = useCallback(
(
hostId: string | null | undefined,
sessionId?: string | null,
): RemoteHistoryHostState => {
if (!hostId || !sessionId) return EMPTY_STATE;
return byKey[cacheKey(hostId, sessionId)] ?? EMPTY_STATE;
},
[byKey],
);
const fetch = useCallback(async (sessionId: string, hostId: string) => {
if (!sessionId || !hostId) return;
const key = cacheKey(hostId, sessionId);
const bridge = netcattyBridge.get();
if (!bridge?.readRemoteHistory) {
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: 'Remote history is not available in this build.',
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const reqId = (requestIdByKey.current[key] ?? 0) + 1;
requestIdByKey.current[key] = reqId;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: true,
error: null,
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
const isStale = () => requestIdByKey.current[key] !== reqId;
try {
for (let attempt = 0; attempt <= PENDING_MAX_RETRIES; attempt += 1) {
const result = await bridge.readRemoteHistory(sessionId, 1000);
if (isStale()) return;
if (!result?.success) {
if (result?.pending && attempt < PENDING_MAX_RETRIES) {
await new Promise((resolve) => {
window.setTimeout(resolve, PENDING_RETRY_MS);
});
if (isStale()) return;
continue;
}
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: result?.pending
? 'Remote history is not ready yet. Try again shortly.'
: (result?.error || 'Failed to read remote history'),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const lists: RemoteHistoryEntry[][] = [];
if (result.shell === 'bash') {
lists.push(parseBashHistory(result.bash ?? ''));
} else if (result.shell === 'zsh') {
lists.push(parseZshHistory(result.zsh ?? ''));
} else if (result.shell === 'fish') {
lists.push(parseFishHistory(result.fish ?? ''));
} else {
lists.push(parseBashHistory(result.bash ?? ''));
lists.push(parseZshHistory(result.zsh ?? ''));
lists.push(parseFishHistory(result.fish ?? ''));
}
const merged = mergeRemoteHistory(lists);
setByKey((prev) => ({
...prev,
[key]: {
entries: merged,
loading: false,
error: null,
fetchedAt: Date.now(),
},
}));
return;
}
} catch (err) {
if (isStale()) return;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: err instanceof Error ? err.message : String(err),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
}
}, []);
const clear = useCallback((hostId: string, sessionId?: string | null) => {
const key = sessionId ? cacheKey(hostId, sessionId) : hostId;
requestIdByKey.current[key] = (requestIdByKey.current[key] ?? 0) + 1;
setByKey((prev) => {
if (!(key in prev)) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}, []);
return { getState, fetch, clear };
}

View File

@@ -16,6 +16,7 @@ SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -72,6 +73,18 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
)));
}, []);
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
)));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
@@ -943,6 +956,8 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,

View File

@@ -46,6 +46,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -87,6 +88,7 @@ import {
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -238,6 +240,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -536,6 +542,8 @@ export const useSettingsState = () => {
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -653,7 +661,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -662,7 +670,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -776,6 +784,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -1014,6 +1029,8 @@ export const useSettingsState = () => {
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1058,7 +1075,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -0,0 +1,199 @@
import { useCallback, useMemo } from 'react';
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useSystemManagerBackend() {
const probeSystemCapabilities = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.probeSystemCapabilities) {
return { success: false as const, error: 'probeSystemCapabilities unavailable' };
}
return bridge.probeSystemCapabilities(sessionId);
}, []);
const listSystemProcesses = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSystemProcesses) {
return { success: false as const, error: 'listSystemProcesses unavailable' };
}
return bridge.listSystemProcesses(sessionId);
}, []);
const signalSystemProcess = useCallback(async (options: {
sessionId: string;
pid: number;
signal?: string;
nice?: number;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.signalSystemProcess) {
return { success: false as const, error: 'signalSystemProcess unavailable' };
}
return bridge.signalSystemProcess(options);
}, []);
const listTmuxSessions = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxSessions) {
return { success: false as const, error: 'listTmuxSessions unavailable' };
}
return bridge.listTmuxSessions(sessionId);
}, []);
const createTmuxSession = useCallback(async (options: {
sessionId: string;
name: string;
command?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.createTmuxSession) {
return { success: false as const, error: 'createTmuxSession unavailable' };
}
return bridge.createTmuxSession(options);
}, []);
const listTmuxWindows = useCallback(async (options: { sessionId: string; sessionName: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxWindows) {
return { success: false as const, error: 'listTmuxWindows unavailable' };
}
return bridge.listTmuxWindows(options);
}, []);
const listTmuxPanes = useCallback(async (options: {
sessionId: string;
sessionName: string;
windowIndex: number;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxPanes) {
return { success: false as const, error: 'listTmuxPanes unavailable' };
}
return bridge.listTmuxPanes(options);
}, []);
const listTmuxClients = useCallback(async (options: { sessionId: string; sessionName?: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxClients) {
return { success: false as const, error: 'listTmuxClients unavailable' };
}
return bridge.listTmuxClients(options);
}, []);
const tmuxAction = useCallback(async (options: { sessionId: string } & TmuxManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.tmuxAction) {
return { success: false as const, error: 'tmuxAction unavailable' };
}
return bridge.tmuxAction(options);
}, []);
const listDockerContainers = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerContainers) {
return { success: false as const, error: 'listDockerContainers unavailable' };
}
return bridge.listDockerContainers(sessionId);
}, []);
const listDockerImages = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerImages) {
return { success: false as const, error: 'listDockerImages unavailable' };
}
return bridge.listDockerImages(sessionId);
}, []);
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getDockerStats) {
return { success: false as const, error: 'getDockerStats unavailable' };
}
return bridge.getDockerStats(options);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
return { success: false as const, error: 'dockerInspect unavailable' };
}
return bridge.dockerInspect(options);
}, []);
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageInspect) {
return { success: false as const, error: 'dockerImageInspect unavailable' };
}
return bridge.dockerImageInspect(options);
}, []);
const dockerAction = useCallback(async (options: {
sessionId: string;
containerId: string;
action: DockerContainerAction;
newName?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerAction) {
return { success: false as const, error: 'dockerAction unavailable' };
}
return bridge.dockerAction(options);
}, []);
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageAction) {
return { success: false as const, error: 'dockerImageAction unavailable' };
}
return bridge.dockerImageAction(options);
}, []);
const openTerminalPopup = useCallback(async (
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
) => {
const bridge = netcattyBridge.get();
if (!bridge?.openTerminalPopup) {
return { success: false as const, error: 'openTerminalPopup unavailable' };
}
return bridge.openTerminalPopup(payload);
}, []);
return useMemo(() => ({
probeSystemCapabilities,
listSystemProcesses,
signalSystemProcess,
listTmuxSessions,
createTmuxSession,
listTmuxWindows,
listTmuxPanes,
listTmuxClients,
tmuxAction,
listDockerContainers,
listDockerImages,
getDockerStats,
dockerInspect,
dockerImageInspect,
dockerAction,
dockerImageAction,
openTerminalPopup,
}), [
probeSystemCapabilities,
listSystemProcesses,
signalSystemProcess,
listTmuxSessions,
createTmuxSession,
listTmuxWindows,
listTmuxPanes,
listTmuxClients,
tmuxAction,
listDockerContainers,
listDockerImages,
getDockerStats,
dockerInspect,
dockerImageInspect,
dockerAction,
dockerImageAction,
openTerminalPopup,
]);
}

View File

@@ -132,6 +132,11 @@ export const useTerminalBackend = () => {
return bridge?.onConnectionReuseFallback?.(cb);
}, []);
const onWindowFullScreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowFullScreenChanged?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
@@ -170,6 +175,32 @@ export const useTerminalBackend = () => {
return bridge.listSerialPorts();
}, []);
const serialYmodemAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.sendSerialYmodem;
}, []);
const selectFileAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectFile;
}, []);
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
return bridge.sendSerialYmodem(sessionId, filePath);
}, []);
const selectFile = useCallback(async (
title?: string,
defaultPath?: string,
filters?: Array<{ name: string; extensions: string[] }>,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectFile) return null;
return bridge.selectFile(title, defaultPath, filters);
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -224,6 +255,10 @@ export const useTerminalBackend = () => {
startLocalSession,
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
selectFileAvailable,
sendSerialYmodem,
selectFile,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -240,6 +275,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
@@ -260,6 +296,10 @@ export const useTerminalBackend = () => {
startLocalSession,
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
selectFileAvailable,
sendSerialYmodem,
selectFile,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -276,6 +316,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
export function useTerminalPopupWindow() {
const close = useCallback(async () => {
await netcattyBridge.get()?.windowClose?.();
}, []);
const setWindowTitle = useCallback(async (title: string) => {
await netcattyBridge.get()?.setWindowTitle?.(title);
}, []);
const onPopupConfig = useCallback((cb: (payload: TerminalPopupPayload) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTerminalPopupConfig) return () => {};
return bridge.onTerminalPopupConfig(cb);
}, []);
return { close, setWindowTitle, onPopupConfig };
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
@@ -33,8 +33,11 @@ import {
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
decryptGroupConfigs,
decryptHosts,
@@ -89,6 +92,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
filePath: key.filePath,
order: key.order,
};
};
@@ -132,6 +136,11 @@ const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] =
return changed ? next : logs;
};
const readLegacyLineTimestampsEnabled = (): boolean => {
const stored = localStorageAdapter.read<Record<string, unknown>>(STORAGE_KEY_TERM_SETTINGS);
return stored?.showLineTimestamps === true;
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
@@ -167,7 +176,7 @@ export const useVaultState = () => {
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
return encryptHosts(cleaned).then((enc) => {
@@ -177,9 +186,10 @@ export const useVaultState = () => {
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
const ver = ++keysWriteVersion.current;
return encryptKeys(data).then((enc) => {
return encryptKeys(cleaned).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
@@ -210,8 +220,9 @@ export const useVaultState = () => {
category: (draft.category || 'key') as KeyCategory,
created: Date.now(),
filePath: draft.filePath,
order: getNextVaultOrder(keys),
};
const updated = [...keys, newKey];
const updated = normalizeVaultOrder([...keys, newKey]);
setKeys(updated);
const ver = ++keysWriteVersion.current;
void encryptKeys(updated).then((enc) => {
@@ -222,26 +233,29 @@ export const useVaultState = () => {
}, [keys]);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const cleaned = normalizeVaultOrder(data);
setIdentities(cleaned);
const ver = ++identitiesWriteVersion.current;
return encryptIdentities(data).then((enc) => {
return encryptIdentities(cleaned).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
setProxyProfiles(data);
const cleaned = normalizeVaultOrder(data);
setProxyProfiles(cleaned);
const ver = ++proxyProfilesWriteVersion.current;
return encryptProxyProfiles(data).then((enc) => {
return encryptProxyProfiles(cleaned).then((enc) => {
if (ver === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
setSnippets(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
const cleaned = normalizeVaultOrder(data);
setSnippets(cleaned);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
}, []);
const updateSnippetPackages = useCallback((data: string[]) => {
@@ -252,11 +266,39 @@ export const useVaultState = () => {
const updateCustomGroups = useCallback((data: string[]) => {
setCustomGroups(data);
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
}, []);
const groupOrderByPath = new Map<string, number>(
data.map((path, index) => [path, (index + 1) * 1000]),
);
const existingConfigByPath = new Map<string, GroupConfig>(
groupConfigs.map((config) => [config.path, config]),
);
const orderedConfigs = data.map((path) => {
const existing = existingConfigByPath.get(path);
const base: GroupConfig = existing ? { ...existing } : { path };
return sanitizeGroupConfig({
...base,
path,
order: groupOrderByPath.get(path),
});
});
const retainedConfigs = groupConfigs.filter((config) => !groupOrderByPath.has(config.path));
const cleanedGroupConfigs = normalizeVaultOrder([
...orderedConfigs,
...retainedConfigs.map(sanitizeGroupConfig),
]);
setGroupConfigs(cleanedGroupConfigs);
const ver = ++groupConfigsWriteVersion.current;
void encryptGroupConfigs(cleanedGroupConfigs).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}, [groupConfigs]);
const updateKnownHosts = useCallback((data: KnownHost[]) => {
setKnownHosts(data);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
const cleaned = normalizeVaultOrder(data);
setKnownHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
}, []);
const updateManagedSources = useCallback((data: ManagedSource[]) => {
@@ -270,7 +312,7 @@ export const useVaultState = () => {
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = data.map(sanitizeGroupConfig);
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
return encryptGroupConfigs(cleaned).then((enc) => {
@@ -306,14 +348,9 @@ export const useVaultState = () => {
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
const newEntry: ShellHistoryEntry = {
...entry,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
setShellHistory((prev) => {
// Keep only the last 1000 entries
const updated = [newEntry, ...prev].slice(0, 1000);
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
@@ -400,6 +437,7 @@ export const useVaultState = () => {
group: "",
tags: [],
protocol: "ssh",
order: getNextVaultOrder(hosts),
};
// Update the known host to mark it as converted using functional update
@@ -413,7 +451,7 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
const updated = normalizeVaultOrder([...prevHosts, sanitizeHost(newHost)]);
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
@@ -423,7 +461,7 @@ export const useVaultState = () => {
});
return newHost;
}, []);
}, [hosts]);
useEffect(() => {
const init = async () => {
@@ -437,7 +475,12 @@ export const useVaultState = () => {
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
const sanitized = normalizeVaultOrder(
migrateHostsFromLegacyLineTimestamps(
decrypted.map(sanitizeHost),
readLegacyLineTimestampsEnabled(),
),
);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
@@ -474,8 +517,9 @@ export const useVaultState = () => {
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
const orderedKeys = normalizeVaultOrder(decryptedKeys);
setKeys(orderedKeys);
encryptKeys(orderedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
@@ -493,8 +537,9 @@ export const useVaultState = () => {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
const orderedIdentities = normalizeVaultOrder(decryptedIds);
setIdentities(orderedIdentities);
encryptIdentities(orderedIdentities).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
@@ -507,8 +552,9 @@ export const useVaultState = () => {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (proxyVer === proxyProfilesWriteVersion.current) {
setProxyProfiles(decryptedProfiles);
encryptProxyProfiles(decryptedProfiles).then((enc) => {
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
setProxyProfiles(orderedProfiles);
encryptProxyProfiles(orderedProfiles).then((enc) => {
if (proxyVer === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
@@ -523,7 +569,11 @@ export const useVaultState = () => {
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
setSnippets(orderedSnippets);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
}
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
@@ -540,9 +590,10 @@ export const useVaultState = () => {
);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
const orderedKnownHosts = normalizeVaultOrder(normalized);
setKnownHosts(orderedKnownHosts);
if (normalized !== savedKnownHosts || orderedKnownHosts !== normalized) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, orderedKnownHosts);
}
}
@@ -570,7 +621,7 @@ export const useVaultState = () => {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -605,7 +656,7 @@ export const useVaultState = () => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
setHosts(normalizeVaultOrder(dec.map(sanitizeHost)));
});
return;
}
@@ -624,7 +675,7 @@ export const useVaultState = () => {
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
setKeys(normalizeVaultOrder(dec));
});
return;
}
@@ -636,7 +687,7 @@ export const useVaultState = () => {
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
setIdentities(normalizeVaultOrder(dec));
});
return;
}
@@ -648,14 +699,14 @@ export const useVaultState = () => {
const writeAtStart = proxyProfilesWriteVersion.current;
decryptProxyProfiles(next).then((dec) => {
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
setProxyProfiles(dec);
setProxyProfiles(normalizeVaultOrder(dec));
});
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
setSnippets(next);
setSnippets(normalizeVaultOrder(next));
return;
}
@@ -673,7 +724,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(normalizeKnownHosts(next));
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
return;
}
@@ -702,7 +753,7 @@ export const useVaultState = () => {
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec.map(sanitizeGroupConfig));
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
});
return;
}

View File

@@ -1,6 +1,16 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export function subscribeWindowFullscreenChanged(
cb: (isFullscreen: boolean) => void,
): () => void {
try {
return netcattyBridge.get()?.onWindowFullScreenChanged?.(cb) ?? (() => {});
} catch {
return () => {};
}
}
export const useWindowControls = () => {
const notifyRendererReady = useCallback(() => {
try {
@@ -45,10 +55,7 @@ export const useWindowControls = () => {
return bridge?.windowIsFullscreen?.() ?? false;
}, []);
const onFullscreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
}, []);
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
const bridge = netcattyBridge.get();

View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import type { Host } from '../../types';
import type { VaultOrderPosition } from '../../domain/vaultOrder';
export interface VaultHostTreeActions {
onDeleteHost: (host: Host) => void;
@@ -16,6 +17,8 @@ export interface VaultHostTreeActions {
cancelInlineHostEdit: () => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
reorderHost: (sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => void;
reorderGroup: (sourcePath: string, targetPath: string, position: VaultOrderPosition) => boolean;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}

View File

@@ -696,6 +696,49 @@ test("applySyncPayload preserves host proxy references when group configs are ab
assert.equal("groupConfigs" in imported, false);
});
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [
{
id: "host-1",
label: "Inherited",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
},
{
id: "host-2",
label: "Explicit",
hostname: "example.net",
username: "root",
tags: [],
os: "linux",
showLineTimestamps: false,
},
],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { showLineTimestamps: true } },
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
const hosts = imported.hosts as SyncPayload["hosts"];
assert.equal(hosts[0]?.showLineTimestamps, true);
assert.equal(hosts[1]?.showLineTimestamps, false);
});
test("applySyncPayload waits for async vault imports", async () => {
let finished = false;
const payload: SyncPayload = {

View File

@@ -24,6 +24,7 @@ import {
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
@@ -31,6 +32,7 @@ import {
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
@@ -64,6 +66,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -78,6 +81,7 @@ import {
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
@@ -192,8 +196,11 @@ const SYNCABLE_TERMINAL_KEYS = [
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
'serverStatsRefreshInterval', 'rendererType',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
@@ -228,6 +235,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -242,6 +250,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -405,6 +414,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -444,6 +455,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
@@ -537,6 +550,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
@@ -575,6 +591,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
);
}
}
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
@@ -615,6 +634,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
for (const key of touched) {
emitAIStateChanged(key);
}
@@ -707,10 +727,11 @@ function applyPayload(
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): Promise<void> {
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,

View File

@@ -2,6 +2,7 @@ import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { UserSkillOption } from './ai/userSkillsState';
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import AgentSelector from './ai/AgentSelector';
@@ -65,6 +66,7 @@ interface AIChatPanelContentProps {
terminalSessions: TerminalSessionSummary[];
selectedUserSkills: UserSkillOption[];
userSkillOptions: UserSkillOption[];
quickMessages: AIQuickMessage[];
addSelectedUserSkill: (slug: string) => void;
removeSelectedUserSkill: (slug: string) => void;
globalPermissionMode: AIPermissionMode;
@@ -112,6 +114,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
terminalSessions,
selectedUserSkills,
userSkillOptions,
quickMessages,
addSelectedUserSkill,
removeSelectedUserSkill,
globalPermissionMode,
@@ -263,6 +266,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
quickMessages={quickMessages}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}

View File

@@ -22,6 +22,7 @@ import {
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
panelViewsEqual,
resolveDisplayedPanelView,
resolveDisplayedSession,
} from './ai/aiPanelViewState';
@@ -47,7 +48,7 @@ import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligib
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
getAIPanelProfilerProps,
@@ -125,6 +126,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
commandBlocklist,
maxIterations = 20,
webSearchConfig,
quickMessages = [],
scopeType,
scopeTargetId,
scopeHostIds,
@@ -252,7 +254,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!isVisible) return;
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
@@ -481,18 +483,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
const isCodexManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
[currentAgentConfig],
);
const isClaudeManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
[currentAgentConfig],
);
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
@@ -529,7 +523,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (!isVisible) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiSdkAgentListModels) return;
@@ -569,7 +563,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
@@ -859,22 +853,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
clearScopeDraft, showScopeSessionView, setActiveSessionId,
]);
const handleStop = useCallback(() => {
if (!activeSessionId) return;
const controller = abortControllersRef.current.get(activeSessionId);
const stopStreamingForSession = useCallback((sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
controller?.abort();
abortControllersRef.current.delete(activeSessionId);
setStreamingForScope(activeSessionId, false);
updateLastMessage(activeSessionId, msg => ({
abortControllersRef.current.delete(sessionId);
setStreamingForScope(sessionId, false);
updateLastMessage(sessionId, (msg) => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
clearAllPendingApprovals(activeSessionId);
clearAllPendingApprovals(sessionId);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiSdkAgentCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
bridge?.aiCattyCancelExec?.(sessionId);
bridge?.aiSdkAgentCancel?.('', sessionId);
}, [setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleStop = useCallback(() => {
if (!activeSessionId) return;
stopStreamingForSession(activeSessionId);
}, [activeSessionId, stopStreamingForSession]);
const handleSelectSession = useCallback(
(sessionId: string) => {
@@ -890,9 +888,43 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
const deletingActiveSession =
activeSessionId === sessionId
|| persistedSessionId === sessionId
|| (
explicitPanelView?.mode === 'session'
&& explicitPanelView.sessionId === sessionId
);
const deletingLastScopedSession =
historySessions.length === 1 && historySessions[0]?.id === sessionId;
const deletedSessionAgentId =
historySessions.find((session) => session.id === sessionId)?.agentId
?? currentAgentId;
if (abortControllersRef.current.has(sessionId) || streamingSessionIds.has(sessionId)) {
stopStreamingForSession(sessionId);
}
deleteSession(sessionId, scopeKey);
if (deletingActiveSession || deletingLastScopedSession) {
setShowHistory(false);
ensureScopeDraft(deletedSessionAgentId);
}
},
[deleteSession, scopeKey],
[
activeSessionId,
abortControllersRef,
currentAgentId,
deleteSession,
ensureScopeDraft,
explicitPanelView,
historySessions,
persistedSessionId,
scopeKey,
stopStreamingForSession,
streamingSessionIds,
],
);
const handleAgentChange = useCallback((agentId: string) => {
@@ -952,6 +984,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
quickMessages={quickMessages}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
@@ -998,6 +1031,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'commandBlocklist',
'maxIterations',
'webSearchConfig',
'quickMessages',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
function aiChatSidePanelPropsAreEqual(

View File

@@ -10,6 +10,7 @@ import type {
ProviderConfig,
WebSearchConfig,
} from '../infrastructure/ai/types';
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
// -------------------------------------------------------------------
@@ -72,6 +73,9 @@ export interface AIChatSidePanelProps {
// Web search
webSearchConfig?: WebSearchConfig | null;
// Quick messages (slash prompts)
quickMessages?: AIQuickMessage[];
// Context
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;

View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
modelPresetsContainId,
shouldLoadSdkRuntimeModels,
} from './AIChatSidePanelHelpers';
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
const presets: AgentModelPreset[] = [
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
{ id: 'claude-sonnet', name: 'Claude Sonnet' },
];
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
});
test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () => {
const agent = (sdkBackend: string): ExternalAgentConfig => ({
id: `discovered_${sdkBackend}`,
name: sdkBackend,
command: sdkBackend,
enabled: true,
sdkBackend,
});
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
});

View File

@@ -26,6 +26,11 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
return tokens.some((token) => token.includes('copilot'));
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return sdkBackend === 'claude' || sdkBackend === 'copilot' || sdkBackend === 'codebuddy';
}
export function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -0,0 +1,554 @@
/**
* HistorySidePanel — command history browser for the terminal side panel.
*
* Two scopes:
* - Host: remote shell history read from the focused session's history file.
* - Global: commands recorded locally as the user types across all sessions.
*
* Uses VariableSizeVirtualList for performance with large lists (up to 1000
* entries). Long commands are truncated in the list; click a row to expand the
* full text inline below that row.
*/
import {
Clipboard as ClipboardIcon,
FileCode,
Globe,
Play,
RefreshCw,
Search,
Terminal as TerminalIcon,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
import { cn } from '../lib/utils';
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
import {
VariableSizeVirtualList,
type VariableSizeVirtualListHandle,
} from './ui/VariableSizeVirtualList';
import { Input } from './ui/input';
export type HistoryPanelScope = 'host' | 'global';
export interface HistorySidePanelProps {
focusedHost: Host | null;
focusedSessionId: string | null;
state: RemoteHistoryHostState;
globalEntries: ShellHistoryEntry[];
onFetch: (sessionId: string, hostId: string) => void;
/** Paste into the terminal without executing (no trailing Enter). */
onPasteToTerminal: (command: string) => void;
/** Write to the terminal and execute (append Enter). */
onRunInTerminal: (command: string) => void;
isVisible?: boolean;
}
const SUPPORTED_PROTOCOLS = new Set(['ssh', 'mosh', 'et']);
const HISTORY_ROW_HEIGHT = 36;
const HISTORY_ROW_WITH_HOST_HEIGHT = 46;
const DETAIL_PADDING_Y = 12;
const DETAIL_LINE_HEIGHT = 16;
const DETAIL_MAX_COMMAND_LINES = 3;
const DETAIL_TIMESTAMP_HEIGHT = 14;
const DETAIL_HOST_LABEL_HEIGHT = 14;
const DETAIL_ACTIONS_HEIGHT = 24;
interface HistoryPanelEntry {
id: string;
command: string;
timestamp?: number;
hostLabel?: string;
}
function getDetailRowHeight(entry: HistoryPanelEntry): number {
const lineCount = Math.min(
entry.command.split('\n').length,
DETAIL_MAX_COMMAND_LINES,
);
const commandHeight = Math.max(lineCount, 1) * DETAIL_LINE_HEIGHT;
const timestampBlock = entry.timestamp ? DETAIL_TIMESTAMP_HEIGHT + 4 : 0;
const hostLabelBlock = entry.hostLabel ? DETAIL_HOST_LABEL_HEIGHT + 2 : 0;
return DETAIL_PADDING_Y + commandHeight + timestampBlock + hostLabelBlock + 4 + DETAIL_ACTIONS_HEIGHT;
}
type HistoryListRow =
| { type: 'entry'; entry: HistoryPanelEntry }
| { type: 'detail'; entry: HistoryPanelEntry };
function buildHistoryListRows(
entries: HistoryPanelEntry[],
selectedEntryId: string | null,
): HistoryListRow[] {
const rows: HistoryListRow[] = [];
for (const entry of entries) {
rows.push({ type: 'entry', entry });
if (selectedEntryId === entry.id) {
rows.push({ type: 'detail', entry });
}
}
return rows;
}
function remoteToPanelEntries(entries: RemoteHistoryEntry[]): HistoryPanelEntry[] {
return entries.map((entry) => ({
id: entry.id,
command: entry.command,
timestamp: entry.timestamp,
}));
}
const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
focusedHost,
focusedSessionId,
state,
globalEntries,
onFetch,
onPasteToTerminal,
onRunInTerminal,
isVisible = true,
}) => {
const { t } = useI18n();
const [scope, setScope] = useState<HistoryPanelScope>('host');
const [search, setSearch] = useState('');
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const listRef = useRef<VariableSizeVirtualListHandle>(null);
const protocol = focusedHost?.protocol;
const isSupportedSession =
!!focusedHost && !!focusedSessionId && SUPPORTED_PROTOCOLS.has(String(protocol ?? 'ssh'));
useEffect(() => {
if (!isVisible || scope !== 'host' || !isSupportedSession || !focusedHost || !focusedSessionId) {
return;
}
if (state.loading) return;
if (state.fetchedAt != null || state.error) return;
onFetch(focusedSessionId, focusedHost.id);
}, [
isVisible,
scope,
isSupportedSession,
focusedHost,
focusedSessionId,
state.loading,
state.fetchedAt,
state.error,
onFetch,
]);
const handleRefresh = useCallback(() => {
if (!focusedHost || !focusedSessionId) return;
onFetch(focusedSessionId, focusedHost.id);
}, [focusedHost, focusedSessionId, onFetch]);
useEffect(() => {
if (scope !== 'host') return;
setSelectedEntryId(null);
setSearch('');
}, [focusedHost?.id, scope]);
useEffect(() => {
setSelectedEntryId(null);
}, [scope]);
const sourceEntries = useMemo((): HistoryPanelEntry[] => {
if (scope === 'global') {
return toGlobalHistoryDisplayEntries(globalEntries);
}
return remoteToPanelEntries(state.entries);
}, [scope, globalEntries, state.entries]);
const filtered = useMemo((): HistoryPanelEntry[] => {
if (!search.trim()) return sourceEntries;
const q = search.toLowerCase();
return sourceEntries.filter(
(entry) =>
entry.command.toLowerCase().includes(q)
|| entry.hostLabel?.toLowerCase().includes(q),
);
}, [sourceEntries, search]);
const listRows = useMemo(
() => buildHistoryListRows(filtered, selectedEntryId),
[filtered, selectedEntryId],
);
const handleSaveAsSnippet = useCallback((entry: HistoryPanelEntry) => {
window.dispatchEvent(
new CustomEvent('netcatty:snippets:add', {
detail: { command: entry.command },
}),
);
}, []);
const handleRowClick = useCallback((entryId: string) => {
setSelectedEntryId((current) => {
const next = current === entryId ? null : entryId;
if (next) {
requestAnimationFrame(() => {
const detailIndex = buildHistoryListRows(filtered, next).findIndex(
(row) => row.type === 'detail' && row.entry.id === next,
);
if (detailIndex >= 0) {
listRef.current?.scrollToIndex(detailIndex, 'auto');
}
});
}
return next;
});
}, [filtered]);
const getRowHeight = useCallback(
(row: HistoryListRow) => {
if (row.type === 'detail') return getDetailRowHeight(row.entry);
if (scope === 'global' && row.entry.hostLabel) return HISTORY_ROW_WITH_HOST_HEIGHT;
return HISTORY_ROW_HEIGHT;
},
[scope],
);
const labels = useMemo(
() => ({
paste: t('history.action.paste'),
run: t('history.action.run'),
save: t('history.action.saveAsSnippet'),
}),
[t],
);
const entryCount = sourceEntries.length;
const showHostEmpty = scope === 'host' && !focusedHost;
const showUnsupported = scope === 'host' && focusedHost && !isSupportedSession;
const showLoading = scope === 'host' && focusedHost && isSupportedSession && state.loading && state.entries.length === 0;
const showError = scope === 'host' && focusedHost && isSupportedSession && state.error;
const showNoRemoteHistory =
scope === 'host'
&& focusedHost
&& isSupportedSession
&& !state.loading
&& !state.error
&& state.entries.length === 0;
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
if (!isVisible) return null;
return (
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="history-panel"
data-history-scope={scope}
>
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('history.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
{scope === 'host' && (
<button
type="button"
onClick={handleRefresh}
disabled={!isSupportedSession || state.loading}
title={t('history.action.refresh')}
aria-label={t('history.action.refresh')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
</button>
)}
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
<div
className="inline-flex max-w-[calc(100%-3.5rem)] items-center gap-0.5"
role="tablist"
aria-label={t('history.scope.label')}
>
<ScopeTab
active={scope === 'host'}
label={focusedHost?.label ?? t('history.tab.host')}
icon={<TerminalIcon size={10} className="shrink-0" />}
onClick={() => setScope('host')}
className="max-w-[9rem]"
/>
<ScopeTab
active={scope === 'global'}
label={t('history.tab.global')}
icon={<Globe size={10} className="shrink-0" />}
onClick={() => setScope('global')}
/>
</div>
{entryCount > 0 && (
<span className="ml-auto shrink-0 opacity-70">
{t('history.meta.count', { count: entryCount })}
</span>
)}
</div>
<div className="flex-1 min-h-0">
{showHostEmpty && (
<EmptyState message={t('history.empty.noSession')} />
)}
{showUnsupported && (
<EmptyState message={t('history.empty.unsupportedProtocol')} />
)}
{showLoading && (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<RefreshCw size={20} className="opacity-60 mb-2 animate-spin" />
<span className="text-xs">{t('history.loading')}</span>
</div>
)}
{showError && (
<div className="px-3 py-4 text-xs text-center">
<div className="text-destructive mb-2">{state.error}</div>
<button
type="button"
onClick={handleRefresh}
className="text-primary hover:underline"
>
{t('history.action.retry')}
</button>
</div>
)}
{showNoRemoteHistory && (
<EmptyState message={t('history.empty.noHistory')} />
)}
{showNoGlobalHistory && (
<EmptyState message={t('history.empty.noGlobalHistory')} />
)}
{filtered.length === 0 && sourceEntries.length > 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
{listRows.length > 0 && (
<VariableSizeVirtualList
ref={listRef}
items={listRows}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
renderItem={(row) => {
if (row.type === 'detail') {
return (
<HistoryDetailStrip
entry={row.entry}
labels={labels}
onRun={() => onRunInTerminal(row.entry.command)}
onPaste={() => onPasteToTerminal(row.entry.command)}
onSave={() => handleSaveAsSnippet(row.entry)}
/>
);
}
return (
<HistoryRow
entry={row.entry}
isSelected={selectedEntryId === row.entry.id}
showHostLabel={scope === 'global'}
labels={labels}
onSelect={() => handleRowClick(row.entry.id)}
onRun={() => onRunInTerminal(row.entry.command)}
onPaste={() => onPasteToTerminal(row.entry.command)}
onSave={() => handleSaveAsSnippet(row.entry)}
/>
);
}}
/>
)}
</div>
</div>
);
};
const ScopeTab: React.FC<{
active: boolean;
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}> = ({ active, label, icon, onClick, className }) => (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
title={label}
className={cn(
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] leading-4 transition-colors min-w-0 shrink whitespace-nowrap',
active
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
className,
)}
>
{icon}
<span className="truncate">{label}</span>
</button>
);
const EmptyState: React.FC<{ message: string }> = ({ message }) => (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<TerminalIcon size={24} className="opacity-40 mb-2" />
<span className="text-xs">{message}</span>
</div>
);
interface HistoryDetailStripProps {
entry: HistoryPanelEntry;
labels: { paste: string; run: string; save: string };
onRun: () => void;
onPaste: () => void;
onSave: () => void;
}
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
({ entry, labels, onRun, onPaste, onSave }) => (
<div
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
data-section="history-detail"
>
<div
className="font-mono text-[11px] leading-4 whitespace-pre-wrap break-words line-clamp-3 overflow-hidden"
style={{ overflowWrap: 'anywhere' }}
>
{entry.command}
</div>
<div className="flex items-center gap-1 mt-1 min-h-6">
<div className="flex-1 min-w-0">
{entry.hostLabel ? (
<span className="block text-[10px] text-muted-foreground truncate">
{entry.hostLabel}
</span>
) : null}
{entry.timestamp ? (
<span className="block text-[10px] text-muted-foreground truncate">
{new Date(entry.timestamp).toLocaleString()}
</span>
) : null}
</div>
<IconButton title={labels.run} onClick={onRun}>
<Play size={12} />
</IconButton>
<IconButton title={labels.paste} onClick={onPaste}>
<ClipboardIcon size={12} />
</IconButton>
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
</div>
</div>
),
);
HistoryDetailStrip.displayName = 'HistoryDetailStrip';
interface HistoryRowProps {
entry: HistoryPanelEntry;
isSelected: boolean;
showHostLabel: boolean;
labels: { paste: string; run: string; save: string };
onSelect: () => void;
onRun: () => void;
onPaste: () => void;
onSave: () => void;
}
const HistoryRow: React.FC<HistoryRowProps> = memo(
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect();
};
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.detail > 1) {
event.preventDefault();
}
};
const rowTitle = isSelected
? undefined
: [entry.command, showHostLabel && entry.hostLabel ? entry.hostLabel : null]
.filter(Boolean)
.join('\n');
return (
<div
className={cn(
'group flex select-none items-center gap-2 px-3 h-full hover:bg-accent/50 transition-colors cursor-pointer',
isSelected && 'bg-accent/30',
)}
role="button"
tabIndex={0}
aria-expanded={isSelected}
title={rowTitle}
onClick={onSelect}
onKeyDown={handleKeyDown}
onMouseDown={handleMouseDown}
>
<div className="w-0 flex-1 min-w-0">
<div className="font-mono text-[11px] truncate whitespace-nowrap">
{entry.command}
</div>
{showHostLabel && entry.hostLabel ? (
<div className="text-[10px] text-muted-foreground truncate">
{entry.hostLabel}
</div>
) : null}
</div>
<div
className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
onClick={(event) => event.stopPropagation()}
>
<IconButton title={labels.run} onClick={onRun}>
<Play size={12} />
</IconButton>
<IconButton title={labels.paste} onClick={onPaste}>
<ClipboardIcon size={12} />
</IconButton>
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
</div>
</div>
);
},
);
HistoryRow.displayName = 'HistoryRow';
const IconButton: React.FC<{
title: string;
onClick: () => void;
children: React.ReactNode;
}> = ({ title, onClick, children }) => (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors"
>
{children}
</button>
);
export const HistorySidePanel = memo(HistorySidePanelInner);
HistorySidePanel.displayName = 'HistorySidePanel';

View File

@@ -370,6 +370,12 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.terminalBehavior")}
>
<ToggleRow
label={t("hostDetails.lineTimestamps")}
hint={t("hostDetails.lineTimestamps.desc")}
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}

View File

@@ -9,6 +9,7 @@ import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActio
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupConfig, GroupNode, Host } from '../types';
@@ -20,10 +21,24 @@ import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
const getTreeGroupDropIntent = (
element: HTMLElement,
clientY: number,
): 'before' | 'inside' | 'after' => {
const rect = element.getBoundingClientRect();
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
if (clientY <= rect.top + edgeSize) return 'before';
if (clientY >= rect.bottom - edgeSize) return 'after';
return 'inside';
};
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
interface HostTreeViewProps {
groupTree: GroupNode[];
hosts: Host[];
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
sortMode?: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths?: Set<string>;
onTogglePath?: (path: string) => void;
onExpandAll?: (paths: string[]) => void;
@@ -55,7 +70,7 @@ interface HostTreeViewProps {
interface TreeNodeProps {
node: GroupNode;
depth: number;
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
sortMode: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
@@ -136,10 +151,26 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const childNodes = useMemo(() => {
if (!node.children) return [];
const nodes = Object.values(node.children) as unknown as GroupNode[];
const originalIndex = new Map(nodes.map((child, index) => [child.path, index]));
const orderByPath = new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
return nodes.sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'manual': {
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
const hasOrderA = typeof orderA === 'number' && Number.isFinite(orderA);
const hasOrderB = typeof orderB === 'number' && Number.isFinite(orderB);
if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB;
if (hasOrderA) return -1;
if (hasOrderB) return 1;
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
}
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
@@ -149,10 +180,10 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return a.name.localeCompare(b.name);
}
});
}, [node.children, sortMode]);
}, [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(() => {
return [...node.hosts].sort((a, b) => {
const sorted = [...node.hosts].sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
@@ -162,10 +193,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
case 'manual':
return 0;
default:
return a.label.localeCompare(b.label);
}
});
if (sortMode === 'manual') return sortByVaultOrder(sorted);
return sorted;
}, [node.hosts, sortMode]);
return (
@@ -184,7 +219,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div
ref={groupRowRef}
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
@@ -199,6 +234,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
if (hasDragType(e.dataTransfer, "group-path")) {
const intent = getTreeGroupDropIntent(e.currentTarget, e.clientY);
if (intent !== "inside") {
setDragOverDropTarget?.(null);
return;
}
}
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
@@ -215,7 +257,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
moveGroup(groupPath, node.path);
}
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
@@ -402,7 +446,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
@@ -562,7 +606,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
const ungroupedHosts = useMemo(() => {
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
return hosts_without_group.sort((a, b) => {
const sorted = hosts_without_group.sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
@@ -572,10 +616,14 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
case 'manual':
return 0;
default:
return a.label.localeCompare(b.label);
}
});
if (sortMode === 'manual') return sortByVaultOrder(sorted);
return sorted;
}, [hosts, sortMode]);
// Sort group tree based on sort mode
@@ -584,6 +632,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'manual':
return 0;
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates

View File

@@ -13,10 +13,11 @@ import {
Upload,
UserPlus,
} from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import type { GroupConfig } from "../domain/models";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -50,6 +51,7 @@ import {
vaultHeaderIconButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import utilities and components from keychain module
import {
@@ -80,8 +82,10 @@ interface KeychainManagerProps {
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
onUpdate: (key: SSHKey) => void;
onReorderKeys?: (keys: SSHKey[]) => void;
onDelete: (id: string) => void;
onSaveIdentity?: (identity: Identity) => void;
onReorderIdentities?: (identities: Identity[]) => void;
onDeleteIdentity?: (id: string) => void;
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
@@ -98,8 +102,10 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
managedSources = [],
onSave,
onUpdate,
onReorderKeys,
onDelete,
onSaveIdentity,
onReorderIdentities,
onDeleteIdentity,
onNewHost: _onNewHost,
onSaveHost,
@@ -147,6 +153,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
const [showHostSelector, setShowHostSelector] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Export panel state
const [exportLocation, setExportLocation] = useState(".ssh");
@@ -171,6 +178,27 @@ echo $3 >> "$FILE"`);
const [showPassphrase, setShowPassphrase] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const keyReorder = useVaultItemReorder({
containerRef: listRef,
viewMode,
dragType: "key-id",
targetAttribute: "data-key-id",
disabled: !onReorderKeys || search.trim().length > 0,
onReorder: (sourceId, targetId, position) => {
onReorderKeys?.(reorderVaultItems(keys, sourceId, targetId, position));
},
});
const identityReorder = useVaultItemReorder({
containerRef: listRef,
viewMode,
dragType: "identity-id",
targetAttribute: "data-identity-id",
disabled: !onReorderIdentities || search.trim().length > 0,
onReorder: (sourceId, targetId, position) => {
onReorderIdentities?.(reorderVaultItems(identities, sourceId, targetId, position));
},
});
const showError = useCallback((message: string, title = t("common.error")) => {
toast.error(message, title);
}, [t]);
@@ -204,18 +232,18 @@ echo $3 >> "$FILE"`);
);
}
return result;
return sortByVaultOrder(result);
}, [keys, activeFilter, search]);
// Filter identities based on search
const filteredIdentities = useMemo(() => {
if (!search.trim()) return identities;
if (!search.trim()) return sortByVaultOrder(identities);
const s = search.toLowerCase();
return identities.filter(
return sortByVaultOrder(identities.filter(
(i) =>
i.label.toLowerCase().includes(s) ||
i.username.toLowerCase().includes(s),
);
));
}, [identities, search]);
// Push a new panel onto the stack
@@ -675,7 +703,26 @@ echo $3 >> "$FILE"`);
</VaultPageHeader>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
<div
ref={listRef}
className="flex-1 overflow-y-auto"
onDragOverCapture={(event) => {
keyReorder.handleDragOverCapture(event);
identityReorder.handleDragOverCapture(event);
}}
onDragOver={(event) => {
keyReorder.handleDragOver(event);
identityReorder.handleDragOver(event);
}}
onDropCapture={(event) => {
keyReorder.handleDropCapture(event);
identityReorder.handleDropCapture(event);
}}
onDragEndCapture={() => {
keyReorder.handleDragEndCapture();
identityReorder.handleDragEndCapture();
}}
>
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
@@ -729,6 +776,7 @@ echo $3 >> "$FILE"`);
(panel.type === "export" && panel.key.id === key.id)
}
isMac={isMacOS()}
reorderProps={keyReorder.getItemReorderProps(key.id, `key:${key.id}`)}
onClick={() => openKeyView(key)}
onEdit={() => openKeyEdit(key)}
onExport={() => openKeyExport(key)}
@@ -768,6 +816,7 @@ echo $3 >> "$FILE"`);
panel.type === "identity" &&
panel.identity?.id === identity.id
}
reorderProps={identityReorder.getItemReorderProps(identity.id, `identity:${identity.id}`)}
onClick={() => {
setPanelStack([{ type: "identity", identity }]);
setDraftIdentity({ ...identity });

View File

@@ -22,6 +22,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
import { fingerprintFromPublicKey } from "../domain/knownHosts";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -45,12 +46,14 @@ import {
vaultHeaderSecondaryButtonClass,
} from "./vault/VaultPageHeader";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
interface KnownHostsManagerProps {
knownHosts: KnownHost[];
hosts: Host[];
onSave: (knownHost: KnownHost) => void;
onUpdate: (knownHost: KnownHost) => void;
onReorder: (knownHosts: KnownHost[]) => void;
onDelete: (id: string) => void;
onConvertToHost: (knownHost: KnownHost) => void;
onImportFromFile: (hosts: KnownHost[]) => void;
@@ -115,12 +118,13 @@ interface HostItemProps {
knownHost: KnownHost;
converted: boolean;
viewMode: ViewMode;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onDelete: (id: string) => void;
onConvertToHost: (knownHost: KnownHost) => void;
}
const HostItem = React.memo<HostItemProps>(
({ knownHost, converted, viewMode, onDelete, onConvertToHost }) => {
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
const { t } = useI18n();
// Disabled to reduce log noise - uncomment for debugging
// console.log('[HostItem] render:', knownHost.hostname);
@@ -129,9 +133,12 @@ const HostItem = React.memo<HostItemProps>(
<ContextMenu>
<ContextMenuTrigger asChild>
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
converted && "opacity-60",
reorderProps?.className,
)}
>
{/* Quick action buttons on hover */}
@@ -202,9 +209,12 @@ const HostItem = React.memo<HostItemProps>(
<ContextMenu>
<ContextMenuTrigger asChild>
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
converted && "opacity-60",
reorderProps?.className,
)}
>
<VaultEntityIcon
@@ -263,6 +273,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
hosts,
onSave: _onSave,
onUpdate: _onUpdate,
onReorder,
onDelete,
onConvertToHost,
onImportFromFile,
@@ -277,9 +288,10 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
"grid",
);
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [sortMode, setSortMode] = useState<SortMode>("manual");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const hasScannedRef = React.useRef(false);
const listRef = React.useRef<HTMLDivElement | null>(null);
const RENDER_LIMIT = 100; // Limit rendered items for performance
// Define handleScanSystem before useEffect that depends on it
@@ -381,12 +393,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
return b.discoveredAt - a.discoveredAt;
case "oldest":
return a.discoveredAt - b.discoveredAt;
case "manual":
return 0;
default:
return 0;
}
});
return result;
return sortMode === "manual" ? sortByVaultOrder(result) : result;
}, [knownHosts, deferredSearch, sortMode]);
// Limit rendered items for performance
@@ -461,6 +475,18 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
const knownHostReorder = useVaultItemReorder({
containerRef: listRef,
viewMode,
dragType: "known-host-id",
targetAttribute: "data-known-host-id",
disabled: deferredSearch.trim().length > 0,
onReorder: (sourceId, targetId, position) => {
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
setSortMode("manual");
},
});
// Memoize the rendered list to prevent re-renders
const renderedItems = useMemo(() => {
return displayedHosts.map((knownHost) => (
@@ -469,6 +495,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
knownHost={knownHost}
converted={convertedMap.get(knownHost.id) || false}
viewMode={viewMode}
reorderProps={knownHostReorder.getItemReorderProps(knownHost.id, `known:${knownHost.id}`)}
onDelete={handleDelete}
onConvertToHost={handleConvertToHost}
/>
@@ -479,6 +506,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
viewMode,
handleDelete,
handleConvertToHost,
knownHostReorder,
]);
return (
@@ -527,6 +555,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
<SortDropdown
value={sortMode}
onChange={setSortMode}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
/>
</div>
@@ -565,12 +594,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
{/* Content */}
<ScrollArea className="flex-1">
<div
ref={listRef}
className={cn(
"p-4",
viewMode === "grid"
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
: "flex flex-col gap-0",
)}
onDragOverCapture={knownHostReorder.handleDragOverCapture}
onDragOver={knownHostReorder.handleDragOver}
onDropCapture={knownHostReorder.handleDropCapture}
onDragEndCapture={knownHostReorder.handleDragEndCapture}
>
{displayedHosts.length === 0 ? (
<div

View File

@@ -9,7 +9,7 @@ import {
Shuffle,
Zap,
} from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
@@ -49,6 +49,7 @@ import {
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import components and utilities from port-forwarding module
import {
@@ -111,6 +112,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
updateRule,
deleteRule,
duplicateRule,
reorderRule,
setRuleStatus,
startTunnel,
stopTunnel,
@@ -128,6 +130,16 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const ruleListRef = useRef<HTMLDivElement | null>(null);
const ruleReorder = useVaultItemReorder({
containerRef: ruleListRef,
viewMode,
dragType: "rule-id",
targetAttribute: "data-rule-id",
disabled: search.trim().length > 0,
onReorder: reorderRule,
});
const resolveEffectiveHost = useCallback(
(host: Host): Host => {
@@ -684,7 +696,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
{/* Sort mode toggle */}
<SortDropdown
value={sortMode}
onChange={setSortMode}
onChange={(mode) => {
if (mode !== "group") setSortMode(mode);
}}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
/>
</div>
@@ -714,11 +729,16 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</div>
<div
ref={ruleListRef}
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
: "flex flex-col gap-2.5",
)}
onDragOverCapture={ruleReorder.handleDragOverCapture}
onDragOver={ruleReorder.handleDragOver}
onDropCapture={ruleReorder.handleDropCapture}
onDragEndCapture={ruleReorder.handleDragEndCapture}
>
{filteredRules.map((rule) => (
<RuleCard
@@ -728,6 +748,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
viewMode={viewMode}
isSelected={selectedRuleId === rule.id}
isPending={pendingOperations.has(rule.id)}
reorderProps={ruleReorder.getItemReorderProps(rule.id, `rule:${rule.id}`)}
onSelect={() => {
setSelectedRuleId(rule.id);
startEditRule(rule);

View File

@@ -13,7 +13,7 @@ import {
SquareTerminal,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import {
@@ -22,6 +22,7 @@ import {
isValidProxyPort,
removeProxyProfileReferences,
} from "../domain/proxyProfiles";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
@@ -67,6 +68,7 @@ import {
vaultProxyHttpIconClass,
vaultProxySocksIconClass,
} from "./vault/VaultEntityIcon";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
@@ -130,6 +132,7 @@ interface ProxyProfileCardProps {
usageCount: number;
viewMode: ProxyProfilesViewMode;
isSelected: boolean;
reorderProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
onClick: () => void;
onEdit: () => void;
onDuplicate: () => void;
@@ -141,6 +144,7 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
usageCount,
viewMode,
isSelected,
reorderProps,
onClick,
onEdit,
onDuplicate,
@@ -158,14 +162,17 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
<ContextMenu>
<ContextMenuTrigger asChild>
<button
{...reorderProps}
type="button"
aria-label={accessibleLabel}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>
@@ -224,6 +231,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
viewMode === "list" ? "list" : "grid";
const [draft, setDraft] = useState<ProxyProfile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const usageByProfileId = useMemo(() => {
const map = new Map<string, number>();
@@ -235,15 +243,26 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const filteredProfiles = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return proxyProfiles;
return proxyProfiles.filter((profile) =>
const result = !q ? proxyProfiles : proxyProfiles.filter((profile) =>
profile.label.toLowerCase().includes(q) ||
profile.config.host.toLowerCase().includes(q) ||
(profile.config.command || "").toLowerCase().includes(q) ||
profile.config.type.toLowerCase().includes(q),
);
return sortByVaultOrder(result);
}, [proxyProfiles, search]);
const profileReorder = useVaultItemReorder({
containerRef: listRef,
viewMode: proxyProfilesViewMode,
dragType: "proxy-profile-id",
targetAttribute: "data-proxy-profile-id",
disabled: search.trim().length > 0,
onReorder: (sourceId, targetId, position) => {
onUpdateProxyProfiles(reorderVaultItems(proxyProfiles, sourceId, targetId, position));
},
});
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
setDraft((prev) => {
if (!prev) return prev;
@@ -397,7 +416,14 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
</div>
</VaultPageHeader>
<div className="flex-1 overflow-y-auto">
<div
ref={listRef}
className="flex-1 overflow-y-auto"
onDragOverCapture={profileReorder.handleDragOverCapture}
onDragOver={profileReorder.handleDragOver}
onDropCapture={profileReorder.handleDropCapture}
onDragEndCapture={profileReorder.handleDragEndCapture}
>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className={vaultSectionTitleClass}>
@@ -439,6 +465,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
usageCount={usageByProfileId.get(profile.id) ?? 0}
viewMode={proxyProfilesViewMode}
isSelected={draft?.id === profile.id}
reorderProps={profileReorder.getItemReorderProps(profile.id, `proxy:${profile.id}`)}
onClick={() => openEdit(profile)}
onEdit={() => openEdit(profile)}
onDuplicate={() => duplicateProfile(profile)}

View File

@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getQuickAddSnippetInitialCommand } from "./QuickAddSnippetDialog.tsx";
test("quick add snippet event can prefill command", () => {
const event = {
detail: { command: "ls -la\npwd" },
} as CustomEvent<{ command?: string }>;
assert.equal(getQuickAddSnippetInitialCommand(event), "ls -la\npwd");
});
test("quick add snippet event defaults to an empty command", () => {
assert.equal(getQuickAddSnippetInitialCommand({} as Event), "");
assert.equal(
getQuickAddSnippetInitialCommand({
detail: { command: 123 },
} as unknown as Event),
"",
);
});

View File

@@ -34,6 +34,11 @@ export interface QuickAddSnippetDialogProps {
onCreatePackage?: (packagePath: string) => void;
}
export function getQuickAddSnippetInitialCommand(event: Event): string {
const detail = (event as CustomEvent<{ command?: unknown }>).detail;
return typeof detail?.command === 'string' ? detail.command : '';
}
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
snippets,
packages,
@@ -54,10 +59,10 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
// terminal-side ScriptsSidePanel + button. We reset form state on
// every open so stale input from a previous cancel does not leak.
useEffect(() => {
const handler = () => {
const handler = (event: Event) => {
setEditing(null);
setLabel('');
setCommand('');
setCommand(getQuickAddSnippetInitialCommand(event));
setPackagePath('');
setNoAutoRun(false);
setOpen(true);

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
import type { Snippet } from "../types";
const snippet = (overrides: Partial<Snippet>): Snippet => ({
id: overrides.id ?? "snippet",
label: overrides.label ?? "Snippet",
command: overrides.command ?? "echo ok",
package: overrides.package ?? "",
order: overrides.order,
});
test("scripts side panel rows keep manual snippet order inside a package", () => {
const rows = buildScriptsSidePanelRows({
snippets: [
snippet({ id: "alpha", label: "Alpha", package: "ops", order: 3000 }),
snippet({ id: "zulu", label: "Zulu", package: "ops", order: 1000 }),
snippet({ id: "beta", label: "Beta", package: "ops", order: 2000 }),
],
packages: ["ops"],
expandedPaths: new Set(["ops"]),
});
assert.deepEqual(
rows.filter((row) => row.type === "snippet").map((row) => row.id),
["zulu", "beta", "alpha"],
);
});

View File

@@ -10,6 +10,7 @@
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import {
@@ -33,6 +34,8 @@ interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (snippet: Snippet) => void;
onSnippetsChange?: (snippets: Snippet[]) => void;
onPackagesChange?: (packages: string[]) => void;
isVisible?: boolean;
}
@@ -63,10 +66,163 @@ const pkgDisplayName = (path: string) => {
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
};
const packageDisplayIndex = (packages: string[], path: string): number => {
const exactIndex = packages.indexOf(path);
if (exactIndex >= 0) return exactIndex;
const childIndex = packages.findIndex((pkg) => pkg.startsWith(`${path}/`));
return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER;
};
let activeScriptsDropIndicator: HTMLElement | null = null;
const clearScriptsDropIndicator = () => {
activeScriptsDropIndicator?.removeAttribute('data-vault-drop-position');
activeScriptsDropIndicator = null;
};
const markScriptsDropIndicator = (target: HTMLElement, position: 'before' | 'after') => {
if (target.dataset.vaultDropPosition === position) return;
clearScriptsDropIndicator();
target.dataset.vaultDropPosition = position;
activeScriptsDropIndicator = target;
};
const markScriptsInsideIndicator = (target: HTMLElement) => {
if (target.dataset.vaultDropPosition === 'inside') return;
clearScriptsDropIndicator();
target.dataset.vaultDropPosition = 'inside';
activeScriptsDropIndicator = target;
};
const getVerticalDropIntent = (
element: HTMLElement,
clientY: number,
): 'before' | 'inside' | 'after' => {
const rect = element.getBoundingClientRect();
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
if (clientY <= rect.top + edgeSize) return 'before';
if (clientY >= rect.bottom - edgeSize) return 'after';
return 'inside';
};
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i += 1) {
const sub = parts.slice(0, i).join('/');
normalizedPackages.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
snippets.forEach((snippet) => {
if (snippet.package) addWithAncestors(snippet.package);
});
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
const bumpCount = (path: string) => {
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
};
for (const snippet of snippets) {
const pkg = snippet.package || '';
const bucket = snippetsByPackage.get(pkg);
if (bucket) bucket.push(snippet);
else snippetsByPackage.set(pkg, [snippet]);
if (pkg === '') {
bumpCount('');
continue;
}
let path = pkg;
while (true) {
bumpCount(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
}
const packagePaths = Array.from(normalizedPackages);
const childPackagesOf = (parent: string | null): string[] => {
const prefix = parent === null ? '' : `${parent}/`;
return packagePaths
.filter((path) => {
if (parent === null) {
const body = path.startsWith('/') ? path.slice(1) : path;
return !body.includes('/');
}
if (!path.startsWith(prefix)) return false;
const rest = path.slice(prefix.length);
return rest.length > 0 && !rest.includes('/');
})
.sort((a, b) => {
const orderDiff = packageDisplayIndex(packages, a) - packageDisplayIndex(packages, b);
if (orderDiff !== 0) return orderDiff;
return pkgDisplayName(a).localeCompare(pkgDisplayName(b));
});
};
const snippetsIn = (pkg: string | null): Snippet[] =>
sortByVaultOrder(snippetsByPackage.get(pkg ?? '') ?? []);
const rows: TreeRow[] = [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
const localSnippets = snippetsIn(pkg);
const hasChildren = children.length > 0 || localSnippets.length > 0;
const isExpanded = expandedPaths.has(pkg);
rows.push({
type: 'package',
id: pkg,
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: descendantCountByPackage.get(pkg) ?? 0,
hasChildren,
isExpanded,
});
if (!isExpanded) return;
children.forEach((child) => walk(child, depth + 1));
localSnippets.forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }),
);
};
snippetsIn(null).forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: 0, snippet, packagePath: '' }),
);
childPackagesOf(null).forEach((root) => walk(root, 0));
return rows;
}
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
onSnippetClick,
onSnippetsChange,
onPackagesChange,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -126,47 +282,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [normalizedPackages, isVisible]);
const snippetIndex = useMemo(() => {
if (!isVisible) {
return {
snippetsByPackage: new Map<string, Snippet[]>(),
descendantCountByPackage: new Map<string, number>(),
};
}
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
const bumpCount = (path: string) => {
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
};
for (const snippet of snippets) {
const pkg = snippet.package || '';
const bucket = snippetsByPackage.get(pkg);
if (bucket) bucket.push(snippet);
else snippetsByPackage.set(pkg, [snippet]);
if (pkg === '') {
bumpCount('');
continue;
}
let path = pkg;
while (true) {
bumpCount(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
}
for (const bucket of snippetsByPackage.values()) {
bucket.sort((a, b) => a.label.localeCompare(b.label));
}
return { snippetsByPackage, descendantCountByPackage };
}, [snippets, isVisible]);
const togglePackage = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
@@ -181,72 +296,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (!isVisible) return null;
const q = search.trim().toLowerCase();
if (!q) return null;
return snippets.filter(
return sortByVaultOrder(snippets.filter(
(s) =>
s.label.toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q),
);
));
}, [snippets, search, isVisible]);
const rows = useMemo<TreeRow[]>(() => {
if (!isVisible) return [];
if (searchMatches !== null) return [];
const out: TreeRow[] = [];
const paths: string[] = [];
normalizedPackages.forEach((p) => paths.push(p));
const childPackagesOf = (parent: string | null): string[] => {
const prefix = parent === null ? '' : parent + '/';
return paths
.filter((p) => {
if (parent === null) {
// Root-level: no "/" inside the body
const body = p.startsWith('/') ? p.slice(1) : p;
return !body.includes('/');
}
if (!p.startsWith(prefix)) return false;
const rest = p.slice(prefix.length);
return rest.length > 0 && !rest.includes('/');
})
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
};
const snippetsIn = (pkg: string | null): Snippet[] =>
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
const localSnippets = snippetsIn(pkg);
const hasChildren = children.length > 0 || localSnippets.length > 0;
const isExpanded = expandedPaths.has(pkg);
out.push({
type: 'package',
id: pkg,
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
hasChildren,
isExpanded,
});
if (!isExpanded) return;
children.forEach((c) => walk(c, depth + 1));
localSnippets.forEach((s) =>
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
);
};
// Orphan / uncategorized snippets first (package === '')
snippetsIn(null).forEach((s) =>
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
);
childPackagesOf(null).forEach((root) => walk(root, 0));
return out;
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
return buildScriptsSidePanelRows({ snippets, packages, expandedPaths });
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
@@ -286,6 +348,163 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
[onSnippetClick],
);
const moveSnippetToPackage = useCallback((snippetId: string, packagePath: string | null) => {
if (!onSnippetsChange) return;
const targetPackage = packagePath || '';
const snippet = snippets.find((item) => item.id === snippetId);
if (!snippet || (snippet.package || '') === targetPackage) return;
onSnippetsChange(snippets.map((item) =>
item.id === snippetId ? { ...item, package: targetPackage } : item,
));
}, [onSnippetsChange, snippets]);
const movePackageToPackage = useCallback((source: string, target: string | null) => {
if (!onPackagesChange || !onSnippetsChange) return;
const name = source.split('/').pop() || '';
const isAbsolute = source.startsWith('/');
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
if (newPath === source || newPath.startsWith(`${source}/`) || packages.includes(newPath)) return;
const updatedPackages = packages.map((path) => {
if (path === source) return newPath;
if (path.startsWith(`${source}/`)) return newPath + path.substring(source.length);
return path;
});
const updatedSnippets = snippets.map((snippet) => {
const packagePath = snippet.package || '';
if (packagePath === source) return { ...snippet, package: newPath };
if (packagePath.startsWith(`${source}/`)) {
return { ...snippet, package: newPath + packagePath.substring(source.length) };
}
return snippet;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
onSnippetsChange(updatedSnippets);
}, [onPackagesChange, onSnippetsChange, packages, snippets]);
const reorderSnippetToTarget = useCallback((
sourceSnippetId: string,
targetSnippetId: string,
position: 'before' | 'after',
) => {
if (!onSnippetsChange || sourceSnippetId === targetSnippetId) return;
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
if (!targetSnippet) return;
const movedSnippets = snippets.map((snippet) =>
snippet.id === sourceSnippetId
? { ...snippet, package: targetSnippet.package || '' }
: snippet,
);
onSnippetsChange(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position));
}, [onSnippetsChange, snippets]);
const reorderPackageToTarget = useCallback((
sourcePackage: string,
targetPackage: string,
position: 'before' | 'after',
) => {
if (!onPackagesChange || sourcePackage === targetPackage) return;
const parentOf = (path: string) => {
const parts = path.split('/').filter(Boolean);
const prefix = path.startsWith('/') ? '/' : '';
return prefix + parts.slice(0, -1).join('/');
};
if (parentOf(sourcePackage) !== parentOf(targetPackage)) return;
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, position));
}, [onPackagesChange, packages]);
const handleRowDragOver = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!onSnippetsChange && !onPackagesChange) return;
const row = event.currentTarget;
const targetSnippetId = row.getAttribute('data-snippet-id');
const targetPackage = row.getAttribute('data-pkg-path');
const isDraggingSnippet = hasDragType(event.dataTransfer, 'snippet-id');
const isDraggingPackage = hasDragType(event.dataTransfer, 'pkg-path');
if (targetSnippetId && isDraggingSnippet) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
const rect = row.getBoundingClientRect();
markScriptsDropIndicator(row, event.clientY < rect.top + rect.height / 2 ? 'before' : 'after');
return;
}
if (targetPackage && isDraggingSnippet) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
markScriptsInsideIndicator(row);
return;
}
if (targetPackage && isDraggingPackage) {
const sourcePackage = event.dataTransfer.getData('pkg-path');
if (
sourcePackage &&
(sourcePackage === targetPackage || targetPackage.startsWith(`${sourcePackage}/`))
) {
event.dataTransfer.dropEffect = 'none';
clearScriptsDropIndicator();
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
const intent = getVerticalDropIntent(row, event.clientY);
if (intent === 'inside') {
markScriptsInsideIndicator(row);
return;
}
markScriptsDropIndicator(row, intent);
return;
}
event.dataTransfer.dropEffect = 'none';
clearScriptsDropIndicator();
}, [onPackagesChange, onSnippetsChange]);
const handleRowDrop = useCallback((event: React.DragEvent<HTMLElement>) => {
if (!onSnippetsChange && !onPackagesChange) return;
const row = event.currentTarget;
clearScriptsDropIndicator();
const targetSnippetId = row.getAttribute('data-snippet-id');
const targetPackage = row.getAttribute('data-pkg-path');
const sourceSnippetId = event.dataTransfer.getData('snippet-id');
const sourcePackage = event.dataTransfer.getData('pkg-path');
if (sourceSnippetId && targetSnippetId) {
event.preventDefault();
event.stopPropagation();
const rect = row.getBoundingClientRect();
reorderSnippetToTarget(
sourceSnippetId,
targetSnippetId,
event.clientY < rect.top + rect.height / 2 ? 'before' : 'after',
);
return;
}
if (sourceSnippetId && targetPackage) {
event.preventDefault();
event.stopPropagation();
moveSnippetToPackage(sourceSnippetId, targetPackage);
return;
}
if (sourcePackage && targetPackage) {
event.preventDefault();
event.stopPropagation();
const intent = getVerticalDropIntent(row, event.clientY);
if (intent === 'inside') movePackageToPackage(sourcePackage, targetPackage);
else reorderPackageToTarget(sourcePackage, targetPackage, intent);
}
}, [
movePackageToPackage,
moveSnippetToPackage,
onPackagesChange,
onSnippetsChange,
reorderPackageToTarget,
reorderSnippetToTarget,
]);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
// the "add" panel pre-opened, so the user does not have to leave the
@@ -366,6 +585,11 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
@@ -379,6 +603,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<PackageRow
row={item.row}
countLabel={item.countLabel}
draggable={Boolean(onPackagesChange || onSnippetsChange)}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onToggle={() => togglePackage(item.row.path)}
/>
);
@@ -387,6 +615,11 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
draggable={Boolean(onSnippetsChange)}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
onDragEnd={clearScriptsDropIndicator}
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
@@ -406,15 +639,29 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
interface PackageRowProps {
row: Extract<TreeRow, { type: 'package' }>;
countLabel: string;
draggable: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
onDrop: (event: React.DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
onToggle: () => void;
}
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
const PackageRow = memo<PackageRowProps>(({ row, countLabel, draggable, onDragOver, onDrop, onDragEnd, onToggle }) => (
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
className="vault-drop-indicator-row w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
style={{ paddingLeft: 8 + row.depth * 14 }}
data-pkg-path={row.path}
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('pkg-path', row.path);
}}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<ChevronRight
size={12}
@@ -435,6 +682,11 @@ interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
onDrop: (event: React.DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
@@ -446,6 +698,11 @@ const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
draggable,
sortableTarget,
onDragOver,
onDrop,
onDragEnd,
onClick,
onEdit,
onDelete,
@@ -454,7 +711,19 @@ const SnippetRow = memo<SnippetRowProps>(({
}) => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>
<div
className="vault-drop-indicator-row"
data-snippet-id={sortableTarget ? snippet.id : undefined}
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('snippet-id', snippet.id);
}}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<Tooltip>
<TooltipTrigger asChild>
<button

View File

@@ -155,6 +155,8 @@ const SettingsAITabContainer: React.FC = () => {
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
setQuickMessages={aiState.setQuickMessages}
/>
</AITabErrorBoundary>
);
@@ -397,6 +399,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<SettingsShortcutsTab
hotkeyScheme={settings.hotkeyScheme}
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
keyBindings={settings.keyBindings}
updateKeyBinding={settings.updateKeyBinding}
resetKeyBinding={settings.resetKeyBinding}

View File

@@ -6,6 +6,7 @@ import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/s
import { cn, isMacPlatform } from '../lib/utils';
import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { Button } from './ui/button';
import { ComboboxOption } from './ui/combobox';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
@@ -26,6 +27,15 @@ import {
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
import {
clearVaultDropIndicator as clearSnippetDropIndicator,
getVaultDropIntent as getPackageDropIntent,
getVaultDropPosition as getDropPosition,
hasVaultDragType as hasDragType,
markVaultDropIndicator as markSnippetDropIndicator,
markVaultInsideDropIndicator as markSnippetInsideIndicator,
useVaultGridLayoutAnimation,
} from './vault/vaultReorderDrag';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -94,7 +104,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE,
'grid',
);
const [sortMode, setSortMode] = useState<SortMode>('az');
const [sortMode, setSortMode] = useState<SortMode>('manual');
const listRef = useRef<HTMLDivElement | null>(null);
const lastPreviewReorderRef = useRef<string | null>(null);
const draggingSnippetIdRef = useRef<string | null>(null);
const draggingPackagePathRef = useRef<string | null>(null);
const [draggingSnippetId, setDraggingSnippetId] = useState<string | null>(null);
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
const historyScrollRef = useRef<HTMLDivElement>(null);
@@ -298,6 +315,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
targets: targetSelection,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
order: editingSnippet.order,
});
setRightPanelMode('none');
}
@@ -336,6 +354,23 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
const displayedPackages = useMemo(() => {
const packageIndexByPath = new Map(packages.map((pkg, index) => [pkg, index]));
const getPackageDisplayOrder = (path: string) => {
const exactIndex = packageIndexByPath.get(path);
if (typeof exactIndex === 'number') return exactIndex;
const childIndex = packages.findIndex((pkg) => pkg.startsWith(path + '/'));
return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER;
};
const sortBySavedPackageOrder = (
items: { name: string; path: string; count: number }[],
) => {
return [...items].sort((a, b) => {
const orderDiff = getPackageDisplayOrder(a.path) - getPackageDisplayOrder(b.path);
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name);
});
};
if (!selectedPackage) {
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
@@ -373,7 +408,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
results.push({ name: displayName, path, count });
});
return results;
return sortBySavedPackageOrder(results);
}
const prefix = selectedPackage + '/';
@@ -381,14 +416,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
return sortBySavedPackageOrder(Array.from(new Set<string>(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}));
}, [packages, selectedPackage, snippets]);
const displayedSnippets = useMemo(() => {
@@ -409,13 +444,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'manual':
return 0;
default:
return 0;
}
});
return result;
return sortMode === 'manual' ? sortByVaultOrder(result) : result;
}, [snippets, selectedPackage, search, sortMode]);
const isSearchActive = search.trim().length > 0;
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
@@ -591,6 +630,178 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onSave({ ...sn, package: pkg || '' });
};
const parentOfPackage = useCallback((path: string) => {
const parts = path.split('/').filter(Boolean);
const prefix = path.startsWith('/') ? '/' : '';
return prefix + parts.slice(0, -1).join('/');
}, []);
const resetSnippetDragState = useCallback(() => {
clearSnippetDropIndicator();
lastPreviewReorderRef.current = null;
draggingSnippetIdRef.current = null;
draggingPackagePathRef.current = null;
setDraggingSnippetId(null);
setDraggingPackagePath(null);
}, []);
const handleReorderDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
if (!(target instanceof HTMLElement)) return;
const isGrid = viewMode === 'grid';
const targetSnippetId = target.getAttribute('data-snippet-id');
const targetPackage = target.getAttribute('data-pkg-path');
const isDraggingSnippet = Boolean(draggingSnippetIdRef.current) || hasDragType(event.dataTransfer, 'snippet-id');
const isDraggingPackage = Boolean(draggingPackagePathRef.current) || hasDragType(event.dataTransfer, 'pkg-path');
if (targetSnippetId && isDraggingSnippet) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
if (!targetSnippet || !sourceSnippet) return;
const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`;
if (lastPreviewReorderRef.current === previewKey) return;
prepareGridLayoutAnimation();
lastPreviewReorderRef.current = previewKey;
const movedSnippets = snippets.map((snippet) =>
snippet.id === sourceSnippetId
? { ...snippet, package: targetSnippet.package || '' }
: snippet,
);
onBulkSave(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position));
setSortMode('manual');
return;
}
markSnippetDropIndicator(target, position, isGrid ? 'x' : 'y');
return;
}
if (targetPackage && isDraggingSnippet) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
markSnippetInsideIndicator(target);
return;
}
if (targetPackage && isDraggingPackage) {
const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path');
if (
sourcePackage &&
targetPackage.startsWith(`${sourcePackage}/`)
) {
event.dataTransfer.dropEffect = 'none';
clearSnippetDropIndicator();
return;
}
const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid);
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (intent === 'inside') {
markSnippetInsideIndicator(target);
return;
}
if (
isGrid &&
sourcePackage &&
parentOfPackage(sourcePackage) === parentOfPackage(targetPackage)
) {
const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`;
if (lastPreviewReorderRef.current !== previewKey) {
prepareGridLayoutAnimation();
lastPreviewReorderRef.current = previewKey;
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent));
setSortMode('manual');
}
return;
}
markSnippetDropIndicator(target, intent, isGrid ? 'x' : 'y');
return;
}
event.dataTransfer.dropEffect = 'none';
clearSnippetDropIndicator();
}, [
onBulkSave,
onPackagesChange,
packages,
parentOfPackage,
prepareGridLayoutAnimation,
snippets,
viewMode,
]);
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
clearSnippetDropIndicator();
if (!(target instanceof HTMLElement)) return;
const isGrid = viewMode === 'grid';
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
const targetSnippetId = target.getAttribute('data-snippet-id');
if (sourceSnippetId && targetSnippetId) {
event.preventDefault();
event.stopPropagation();
if (sourceSnippetId === targetSnippetId) {
lastPreviewReorderRef.current = null;
return;
}
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
if (!targetSnippet || !sourceSnippet) return;
const movedSnippets = snippets.map((snippet) =>
snippet.id === sourceSnippetId
? { ...snippet, package: targetSnippet.package || '' }
: snippet,
);
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`;
if (!isGrid || lastPreviewReorderRef.current !== previewKey) {
prepareGridLayoutAnimation();
onBulkSave(reorderVaultItems(
movedSnippets,
sourceSnippetId,
targetSnippetId,
position,
));
}
lastPreviewReorderRef.current = null;
setSortMode('manual');
return;
}
const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path');
const targetPackage = target.getAttribute('data-pkg-path');
if (sourcePackage && targetPackage) {
event.preventDefault();
event.stopPropagation();
if (sourcePackage === targetPackage) {
lastPreviewReorderRef.current = null;
return;
}
const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid);
if (intent === 'inside') return;
if (parentOfPackage(sourcePackage) !== parentOfPackage(targetPackage)) return;
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`;
if (!isGrid || lastPreviewReorderRef.current !== previewKey) {
prepareGridLayoutAnimation();
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent));
}
lastPreviewReorderRef.current = null;
setSortMode('manual');
}
}, [
onBulkSave,
onPackagesChange,
packages,
parentOfPackage,
prepareGridLayoutAnimation,
snippets,
viewMode,
]);
const packageOptions: ComboboxOption[] = useMemo(() => {
const allPaths = new Set<string>();
@@ -792,7 +1003,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</div>
)}
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
<div
ref={listRef}
className="flex-1 space-y-3 overflow-y-auto px-4 pb-4"
onDragOverCapture={handleReorderDragOver}
onDropCapture={handleReorderDrop}
onDragEndCapture={resetSnippetDragState}
>
{displayedPackages.length > 0 && !search.trim() && (
<>
<div className="flex items-center justify-between">
@@ -808,23 +1025,35 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer overflow-hidden",
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
data-pkg-path={pkg.path}
data-vault-grid-item={`snippet-package:${pkg.path}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingPackagePath === pkg.path ? 'true' : undefined}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('pkg-path', pkg.path);
draggingPackagePathRef.current = pkg.path;
setDraggingPackagePath(pkg.path);
lastPreviewReorderRef.current = null;
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const sId = e.dataTransfer.getData('snippet-id');
const pPath = e.dataTransfer.getData('pkg-path');
const sId = draggingSnippetIdRef.current || e.dataTransfer.getData('snippet-id');
const pPath = draggingPackagePathRef.current || e.dataTransfer.getData('pkg-path');
if (sId) moveSnippet(sId, pkg.path);
if (pPath) movePackage(pPath, pkg.path);
if (
pPath &&
getPackageDropIntent(e.currentTarget, e.clientX, e.clientY, viewMode === 'grid') === 'inside'
) {
movePackage(pPath, pkg.path);
}
}}
onClick={() => setSelectedPackage(pkg.path)}
>
@@ -864,15 +1093,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer overflow-hidden",
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
)}
draggable
data-snippet-id={isSearchActive ? undefined : snippet.id}
data-vault-grid-item={`snippet:${snippet.id}`}
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
draggable={!isSearchActive}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('snippet-id', snippet.id);
draggingSnippetIdRef.current = snippet.id;
setDraggingSnippetId(snippet.id);
lastPreviewReorderRef.current = null;
}}
onClick={() => handleEdit(snippet)}
>

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import { Activity, Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -91,6 +91,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keys,
identities,
snippets,
snippetPackages = [],
compactToolbar = false,
chainHosts = [],
themePreviewId,
knownHosts = [],
@@ -131,7 +133,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenSftp,
onTerminalCwdChange,
onOpenScripts,
onOpenHistory,
onOpenTheme,
onOpenSystem,
isBroadcastEnabled,
onToggleBroadcast,
onToggleComposeBar,
@@ -145,6 +149,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferTerminalResize = isResizing || layoutSuppressActive;
const deferTerminalResizeRef = useRef(deferTerminalResize);
deferTerminalResizeRef.current = deferTerminalResize;
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -234,7 +240,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
const {
resizeSession,
selectFile,
selectFileAvailable,
sendSerialYmodem,
serialYmodemAvailable,
setSessionEncoding,
} = terminalBackend;
@@ -448,6 +461,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
const supportsRemoteImagePaste =
!isLocalConnection &&
!isSerialConnection &&
host.protocol !== "telnet" &&
host.protocol !== "mosh" &&
!host.moshEnabled &&
host.protocol !== "et" &&
!host.etEnabled;
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
// network devices. See isNetworkDevice above for why the gating uses the
@@ -456,6 +477,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
const isSystemSidebarEligible =
!!onOpenSystem &&
isSupportedOs &&
!isLocalConnection &&
!isSerialConnection &&
host.protocol !== 'telnet';
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
// terminal. We just forward `isSupportedOs` via ctx.
@@ -797,6 +824,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}, []);
const broadcastUserPasteData = useCallback((data: string) => {
if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId);
return true;
}
return false;
}, [sessionId]);
const executeSnippetCommand = useCallback((
command: string,
noAutoRun?: boolean,
@@ -851,7 +886,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBroadcastEnabledRef,
onBroadcastInputRef,
isLocalConnection,
supportsRemoteImagePaste,
terminalBackend,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
scrollToBottomAfterProgrammaticInput,
});
// Kept fresh on every render so the mouseTracking capture handler at
// handleContextMenuCapture (which is bound once per sessionId) can
@@ -890,6 +928,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setShowSFTP(true);
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
const handleSendYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
if (!selectFileAvailable() || !serialYmodemAvailable()) {
toast.error(t("terminal.ymodem.unavailable"));
return;
}
try {
const filePath = await selectFile(
t("terminal.ymodem.selectFile"),
undefined,
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
);
if (!filePath) return;
const fileName = filePath.split(/[\\/]/).pop() || filePath;
toast.info(t("terminal.ymodem.started", { fileName }));
const result = await sendSerialYmodem(sessionRef.current || sessionId, filePath);
if (result.success) {
toast.success(t("terminal.ymodem.complete", { fileName: result.fileName || fileName }));
} else {
toast.error(result.error || t("terminal.ymodem.failed"));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("terminal.ymodem.failed"));
}
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
@@ -1059,10 +1125,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useTerminalFilePaste({
isLocalConnection,
supportsRemoteImagePaste,
status,
termRef,
sessionRef,
terminalBackend,
resolveSftpInitialPath,
scrollOnPasteRef,
onPasteData: broadcastUserPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
});
@@ -1071,8 +1141,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalToolbar
status={status}
host={host}
compactToolbar={compactToolbar}
snippets={snippets}
snippetPackages={snippetPackages}
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -1085,20 +1161,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSetTerminalEncoding={handleSetTerminalEncoding}
/>
), [
compactToolbar,
executeSnippet,
handleOpenSFTP,
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSearch,
host,
inWorkspace,
isSerialConnection,
isComposeBarOpen,
isSearchOpen,
isWorkspaceComposeBarOpen,
onCloseSession,
onOpenScripts,
onOpenHistory,
onOpenTheme,
onToggleComposeBar,
onUpdateHost,
sessionId,
snippetPackages,
snippets,
status,
terminalEncoding,
]);
@@ -1118,9 +1201,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -41,6 +41,8 @@ const baseProps = {
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
updateSnippets: () => {},
updateSnippetPackages: () => {},
onSplitSession: () => {},
onConnectToHost: () => {},
toggleScriptsSidePanelRef: { current: null },
@@ -124,6 +126,24 @@ test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
);
});
test("TerminalLayer re-renders when snippet save handlers change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, updateSnippets: () => {} } as never,
),
false,
);
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, updateSnippetPackages: () => {} } as never,
),
false,
);
});
test("TerminalLayer re-renders when SSH debug logging changes", () => {
assert.equal(
terminalLayerAreEqual(

View File

@@ -1,4 +1,4 @@
import { FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
@@ -9,6 +9,7 @@ import {
shouldMarkSessionActivity,
} from '../application/state/sessionActivity';
import { sessionActivityStore } from '../application/state/sessionActivityStore';
import { sessionCapabilitiesStore } from '../application/state/sessionCapabilitiesStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
@@ -24,12 +25,15 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { HistorySidePanel } from './HistorySidePanel';
import { useRemoteHistoryState } from '../application/state/useRemoteHistoryState';
import { resolveSnippetCommand } from './SnippetExecutionProvider';
import type { Snippet } from '../types';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
@@ -100,12 +104,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
onUpdateHost,
onAddKnownHost,
onCommandExecuted,
shellHistory = [],
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
@@ -121,6 +128,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
updateSnippets,
updateSnippetPackages,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -164,6 +173,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Stable callback references for Terminal components
const handleCloseSession = useCallback((sessionId: string) => {
sessionCapabilitiesStore.delete(sessionId);
onCloseSession(sessionId);
}, [onCloseSession]);
@@ -296,6 +306,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
@@ -479,26 +490,28 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
let hostForSession: Host;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
map.set(session.id, existingHost);
hostForSession = existingHost;
} else {
map.set(session.id, {
hostForSession = {
...existingHost,
protocol,
port,
moshEnabled,
etEnabled,
});
};
}
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
} else {
// Create stable fallback host object
const fallbackProtocol = session.protocol ?? 'local' as const;
map.set(session.id, {
const fallbackHost: Host = {
id: session.hostId,
label: session.hostLabel || 'Local Terminal',
hostname: session.hostname || 'localhost',
@@ -523,7 +536,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
});
};
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
}
}
return map;
@@ -619,6 +633,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [hostMap, sessions, keys, identities]);
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
@@ -630,7 +652,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateTerminalFontSize]);
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
@@ -678,6 +700,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
if (panel === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
}, []);
@@ -753,6 +779,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
refocusTerminalSession(sessionIdToRefocus);
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
@@ -866,12 +893,20 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSwitchSidePanelTab('theme');
}, [handleSwitchSidePanelTab]);
const handleOpenHistory = useCallback(() => {
handleSwitchSidePanelTab('history');
}, [handleSwitchSidePanelTab]);
// Open AI chat side panel (side-panel rail button: a plain switch that is a
// no-op when AI is already the active sub-panel, matching the other rail tabs)
const handleOpenAI = useCallback(() => {
handleSwitchSidePanelTab('ai');
}, [handleSwitchSidePanelTab]);
const handleOpenSystem = useCallback(() => {
handleSwitchSidePanelTab('system');
}, [handleSwitchSidePanelTab]);
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
const text = selection.trim();
if (!text) return;
@@ -937,6 +972,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
textarea?.focus();
}, [terminalBackend]);
const remoteHistory = useRemoteHistoryState();
const handleHistoryPaste = useCallback(
(command: string) => handleSnippetClickForFocusedSession(command, true),
[handleSnippetClickForFocusedSession],
);
const handleHistoryRun = useCallback(
(command: string) => handleSnippetClickForFocusedSession(command, false),
[handleSnippetClickForFocusedSession],
);
const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
@@ -1000,6 +1045,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTerminalPreviewVars,
clearTopTabsPreviewVars,
FolderTree,
History,
HistorySidePanel,
MessageSquare,
Palette,
PanelLeft,
@@ -1024,10 +1071,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCommandExecuted,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
handleHistoryRun,
handleOpenHistory,
handleOpenSftp,
handleOpenScripts,
handleOpenTheme,
handleOpenAI,
handleOpenSystem,
handleOsDetected,
handlePendingTerminalSelectionConsumed,
handlePendingUploadHandled,
@@ -1062,6 +1113,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
mountedAiTabIds: aiMountedTabIds,
mountedSftpTabIds,
scriptsMountedTabIds,
systemMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onConnectToHost,
@@ -1083,10 +1135,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onUpdateTerminalThemeId,
pendingTerminalSelectionForAI,
refocusActiveTerminalSession,
refocusTerminalSession,
remoteHistory,
shellHistory,
resolveSftpHostForTab,
ScriptsSidePanel,
sessionActivityStore,
@@ -1101,6 +1157,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setPendingTerminalSelectionForAI,
setAiMountedTabIds,
setScriptsMountedTabIds,
setSystemMountedTabIds,
setThemeMountedTabIds,
setSidePanelOpenTabs,
setSidePanelWidth,
@@ -1145,6 +1202,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
TooltipContent,
TooltipTrigger,
updateHosts,
updateSnippetPackages,
updateSnippets,
X,
Zap,
validAIScopeTargetIds,

View File

@@ -0,0 +1,358 @@
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { useSettingsState } from '../application/state/useSettingsState';
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
import type { TerminalPopupPayload } from '../domain/systemManager/types';
import type { TerminalTheme } from '../domain/models';
import type { Host } from '../types';
import { cn } from '../lib/utils';
const Terminal = lazy(() => import('./Terminal'));
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
const POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS = 900;
const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500;
const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000;
type PopupThemeVars = React.CSSProperties & Record<string, string>;
const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => {
const { colors } = theme;
return {
'--terminal-popup-bg': colors.background,
'--terminal-popup-fg': colors.foreground,
'--terminal-popup-muted': colors.foreground,
'--terminal-popup-accent': colors.cursor,
'--terminal-popup-control-hover': `color-mix(in srgb, ${colors.foreground} 10%, transparent)`,
};
};
function TerminalPopupWindowControls({ mac, onClose }: { mac: boolean; onClose: () => void }) {
const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls();
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
let cancelled = false;
void fetchIsMaximized().then((value) => {
if (!cancelled) setIsWindowMaximized(!!value);
});
const handleResize = () => {
void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value));
};
window.addEventListener('resize', handleResize);
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
};
}, [fetchIsMaximized]);
const handleMaximize = async () => {
const value = await maximize();
setIsWindowMaximized(!!value);
};
if (mac) return null;
const buttonClass =
'app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-muted)] transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:text-[color:var(--terminal-popup-fg)]';
return (
<div className="app-no-drag ml-auto flex h-10 shrink-0 items-center">
<button type="button" onClick={() => void minimize()} className={buttonClass} aria-label="Minimize">
<Minus size={15} />
</button>
<button type="button" onClick={() => void handleMaximize()} className={buttonClass} aria-label={isWindowMaximized ? 'Restore' : 'Maximize'}>
{isWindowMaximized ? <Copy size={14} /> : <Square size={13} />}
</button>
<button
type="button"
onClick={onClose}
className="app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-fg)] opacity-80 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
aria-label="Close"
>
<X size={16} />
</button>
</div>
);
}
function TerminalPopupSpinner() {
return (
<div className="h-full flex-1 flex items-center justify-center bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]">
<svg
width="28"
height="28"
viewBox="0 0 28 28"
aria-label="Loading"
className="opacity-80"
>
<circle
cx="14"
cy="14"
r="11"
fill="none"
stroke="currentColor"
strokeWidth="2"
opacity="0.18"
/>
<path
d="M25 14a11 11 0 0 0-11-11"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
>
<animateTransform
attributeName="transform"
dur="0.75s"
from="0 14 14"
repeatCount="indefinite"
to="360 14 14"
type="rotate"
/>
</path>
</svg>
</div>
);
}
function TerminalPopupBlank() {
return (
<div className="h-full flex-1 bg-[color:var(--terminal-popup-bg)]" />
);
}
function TerminalPopupStartupError({
message,
closeLabel,
onClose,
}: {
message: string;
closeLabel: string;
onClose: () => void;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[color:var(--terminal-popup-bg)] px-6 text-center text-[color:var(--terminal-popup-fg)]">
<Unplug size={24} className="mb-3 opacity-45" />
<div className="max-w-[300px] text-xs leading-5 opacity-70">{message}</div>
<button
type="button"
onClick={onClose}
className="app-no-drag mt-4 h-7 rounded px-3 text-[11px] opacity-70 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
>
{closeLabel}
</button>
</div>
);
}
function TerminalPopupTitleIcon({ icon }: { icon: TerminalPopupPayload['icon'] }) {
if (!icon) return null;
if (icon.kind !== 'image' || !icon.src) return null;
return (
<span
className="pointer-events-none ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px]"
style={{
backgroundColor: icon.backgroundColor ?? 'transparent',
}}
>
<img
src={icon.src}
alt={icon.alt ?? ''}
width={11}
height={11}
className="max-h-[11px] max-w-[11px] rounded-[2px] object-contain"
draggable={false}
/>
</span>
);
}
/** Fallback when the parent session's host is no longer in the vault (e.g. quick connect). */
function buildHostFromSession(source: TerminalPopupPayload['sourceSession']): Host {
return {
id: source.hostId,
label: source.hostLabel,
hostname: source.hostname,
username: source.username,
port: source.port ?? (source.protocol === 'local' ? undefined : 22),
protocol: source.protocol === 'local' ? 'local' : 'ssh',
tags: [],
os: 'linux',
moshEnabled: source.moshEnabled,
etEnabled: source.etEnabled,
charset: source.charset,
};
}
function TerminalPopupPageInner() {
const { t } = useI18n();
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
const settings = useSettingsState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const sessionId = useMemo(() => crypto.randomUUID(), []);
const popupThemeVars = useMemo(
() => buildPopupThemeVars(settings.currentTerminalTheme),
[settings.currentTerminalTheme],
);
useEffect(() => {
const unsubscribe = onPopupConfig((payload) => {
setConfig(payload);
if (payload.title) {
void setWindowTitle(payload.title);
}
});
// Main delivers the popup payload as soon as the renderer reports ready
// (and destroys the window if it never does) — so report ready only after
// the config listener above is registered.
notifyRendererReady();
return unsubscribe;
}, [notifyRendererReady, onPopupConfig, setWindowTitle]);
useEffect(() => {
return onWindowCommandCloseRequested(() => {
void close();
});
}, [close, onWindowCommandCloseRequested]);
const host = useMemo(() => {
if (!config) return null;
const vaultHost = hosts.find((h) => h.id === config.sourceSession.hostId);
return vaultHost ?? buildHostFromSession(config.sourceSession);
}, [config, hosts]);
const reuseId = useMemo(() => {
if (!config) return undefined;
return canReuseTerminalConnection(config.sourceSession)
? config.parentSessionId
: undefined;
}, [config]);
const ready = Boolean(config && host && vaultInitialized);
const startupRevealDelayMs = useMemo(() => {
if (!config?.startupCommand) return 0;
const configuredDelay = settings.terminalSettings?.startupCommandDelayMs;
const startupDelay = typeof configuredDelay === 'number' && Number.isFinite(configuredDelay)
? Math.max(0, configuredDelay)
: 600;
return Math.min(
POPUP_STARTUP_REVEAL_MAX_DELAY_MS,
Math.max(POPUP_STARTUP_REVEAL_MIN_DELAY_MS, startupDelay + POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS),
);
}, [config?.startupCommand, settings.terminalSettings?.startupCommandDelayMs]);
const revealTerminal = useCallback(() => {
setTerminalReady(true);
}, []);
useEffect(() => {
setTerminalReady(false);
setStartupError(null);
}, [config?.popupId, sessionId]);
useEffect(() => {
if (!ready) return undefined;
const timeout = window.setTimeout(() => setTerminalReady(true), startupRevealDelayMs);
return () => window.clearTimeout(timeout);
}, [config?.popupId, ready, startupRevealDelayMs]);
return (
<div
className="h-screen flex flex-col overflow-hidden bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]"
data-section="terminal-popup"
style={popupThemeVars}
>
<div
className="app-drag relative shrink-0 h-9 flex items-center bg-[color:var(--terminal-popup-bg)]"
data-section="terminal-popup-titlebar"
>
{isMac && <div className="h-9 w-[92px] shrink-0" />}
<TerminalPopupTitleIcon icon={config?.icon} />
<div className={cn(
'min-w-0 flex-1 pr-3 text-left text-[12px] font-medium text-[color:var(--terminal-popup-fg)] opacity-70',
config?.icon ? 'pl-1.5' : 'pl-3',
!isMac && 'pl-4 text-left',
)}>
<div className="max-w-full truncate">
{config?.title ?? ''}
</div>
</div>
{!isMac && <TerminalPopupWindowControls mac={false} onClose={() => void close()} />}
</div>
{!ready || !config || !host ? (
<TerminalPopupSpinner />
) : startupError ? (
<TerminalPopupStartupError
message={startupError}
closeLabel={t('common.close')}
onClose={() => void close()}
/>
) : (
<div className="relative flex-1 min-h-0 flex flex-col bg-[color:var(--terminal-popup-bg)]">
<Suspense fallback={<TerminalPopupBlank />}>
<Terminal
host={host}
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
compactToolbar
knownHosts={knownHosts}
isVisible
isFocused
fontFamilyId={settings.terminalFontFamilyId}
fontSize={settings.terminalFontSize}
terminalTheme={settings.currentTerminalTheme}
followAppTerminalTheme={settings.followAppTerminalTheme}
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
sessionId={sessionId}
startupCommand={config.startupCommand}
reuseConnectionFromSessionId={reuseId}
onCloseSession={() => {
void close();
}}
onSessionExit={(_closedSessionId, evt) => {
if (shouldCloseTerminalPopupOnExit(evt)) {
void close();
return;
}
if (!terminalReady && config.startupCommand) {
setStartupError(t('systemManager.popup.startupFailed'));
}
}}
onStatusChange={(_changedSessionId, status) => {
if (!config.startupCommand && status === 'connected') revealTerminal();
}}
onTerminalDataCapture={revealTerminal}
/>
</Suspense>
{!terminalReady && (
<div className="pointer-events-none absolute inset-0 z-10">
<TerminalPopupSpinner />
</div>
)}
</div>
)}
</div>
);
}
export default function TerminalPopupPage() {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TerminalPopupPageInner />
</I18nProvider>
);
}

View File

@@ -85,6 +85,14 @@ test("host tree toggle appears with opacity only and no bounce animation", () =>
assert.doesNotMatch(toggleSlotCss, /scale/);
});
test("host tree toggle exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-host-tree-toggle"/);
});
test("quick switcher plus button exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
});
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
assert.match(topTabsSource, /hostTreeChromeReady/);
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);

View File

@@ -812,6 +812,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<div
ref={hostTreeToggleSlotRef}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
data-section="top-tabs-host-tree-toggle"
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
style={noDragRegionStyle}
>
@@ -879,6 +880,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
data-section="top-tabs-quick-switcher-toggle"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}

View File

@@ -134,9 +134,18 @@ test("Hosts grouped sort mode is restored from storage", () => {
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
const markup = renderVault("unknown-sort", [
host("zulu", "Zulu Host", 2),
host("alpha", "Alpha Host", 1),
{ ...host("zulu", "Zulu Host", 2), order: 1000 },
{ ...host("alpha", "Alpha Host", 1), order: 2000 },
]);
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts manual sort mode uses saved order", () => {
const markup = renderVault("manual", [
{ ...host("alpha", "Alpha Host", 1), order: 2000 },
{ ...host("zulu", "Zulu Host", 2), order: 1000 },
]);
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});

View File

@@ -49,6 +49,11 @@ import {
upsertHostById,
} from "../domain/host";
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
import {
reorderVaultItems,
reorderVaultStrings,
type VaultOrderPosition,
} from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
@@ -125,12 +130,35 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
return next.every((host, index) => {
const current = previous[index];
return (
current?.id === host.id &&
current.order === host.order &&
current.group === host.group &&
current.label === host.label &&
current.managedSourceId === host.managedSourceId
);
});
};
const haveSameGroupConfigs = (previous: GroupConfig[], next: GroupConfig[]) => {
if (previous.length !== next.length) return false;
return next.every((config, index) => {
const current = previous[index];
return current?.path === config.path && current.order === config.order;
});
};
const VAULT_SIDEBAR_MIN_WIDTH = 56;
const VAULT_SIDEBAR_DEFAULT_WIDTH = 208;
const VAULT_SIDEBAR_MAX_WIDTH = 320;
const VAULT_SIDEBAR_LABEL_THRESHOLD = 132;
const isSortMode = (value: string): value is SortMode =>
value === "manual" ||
value === "az" ||
value === "za" ||
value === "newest" ||
@@ -308,7 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const [sortMode, setSortMode] = useStoredString<SortMode>(
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
"az",
"manual",
isSortMode,
);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@@ -656,6 +684,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
visibleDisplayedHosts,
} = useVaultHostCollections({
customGroups,
groupConfigs,
hosts,
knownHosts,
onConvertKnownHost,
@@ -926,6 +955,70 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setSelectedGroupPath(newPath);
}
};
const reorderHost = useCallback((sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => {
const source = hostsRef.current.find((host) => host.id === sourceHostId);
const target = hostsRef.current.find((host) => host.id === targetHostId);
if (!source || !target) return;
const targetGroup = target.group || "";
const targetManagedSource = managedSources
.filter((sourceInfo) => targetGroup === sourceInfo.groupName || targetGroup.startsWith(`${sourceInfo.groupName}/`))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const updatedHosts = hostsRef.current.map((host) =>
host.id === sourceHostId
? {
...host,
label:
targetManagedSource && (!host.protocol || host.protocol === "ssh")
? host.label.replace(/\s/g, "")
: host.label,
group: targetGroup,
managedSourceId:
targetManagedSource && (!host.protocol || host.protocol === "ssh")
? targetManagedSource.id
: undefined,
}
: host,
);
const reorderedHosts = reorderVaultItems(updatedHosts, sourceHostId, targetHostId, position);
if (haveSameHostOrderResult(hostsRef.current, reorderedHosts)) return;
onUpdateHosts(reorderedHosts);
setSortMode("manual");
}, [managedSources, onUpdateHosts, setSortMode]);
const reorderGroup = useCallback((sourcePath: string, targetPath: string, position: VaultOrderPosition) => {
const parentOf = (path: string) => {
const parts = path.split("/").filter(Boolean);
return parts.slice(0, -1).join("/");
};
if (parentOf(sourcePath) !== parentOf(targetPath)) return false;
const sortableGroups = Array.from(new Set([...customGroups, sourcePath, targetPath]));
const updatedGroups = reorderVaultStrings(sortableGroups, sourcePath, targetPath, position);
const orderByPath = new Map(updatedGroups.map((path, index) => [path, (index + 1) * 1000]));
const configByPath = new Map<string, GroupConfig>(groupConfigs.map((config) => [config.path, config]));
const nextConfigs: GroupConfig[] = [
...updatedGroups.map((path) => {
const existing = configByPath.get(path);
const base: GroupConfig = existing ? { ...existing } : { path };
return {
...base,
order: orderByPath.get(path),
};
}),
...groupConfigs.filter((config) => !orderByPath.has(config.path)),
];
if (
updatedGroups.length === customGroups.length &&
updatedGroups.every((path, index) => path === customGroups[index]) &&
haveSameGroupConfigs(groupConfigs, nextConfigs)
) {
return true;
}
onUpdateCustomGroups(updatedGroups);
onUpdateGroupConfigs(nextConfigs);
setSortMode("manual");
return true;
}, [customGroups, groupConfigs, onUpdateCustomGroups, onUpdateGroupConfigs, setSortMode]);
const {
getDropTargetClasses,
handleUnmanageGroup,
@@ -981,6 +1074,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleUnmanageGroup,
moveHostToGroup,
moveGroup,
reorderHost,
reorderGroup,
managedGroupPaths,
startInlineNewGroup,
startInlineRenameGroup,
@@ -1036,7 +1131,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedGroupPaths={managedGroupPaths}
onConfirmDelete={deleteGroupPath}
/>
<VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, cancelInlineGroupEdit, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />
<VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, cancelInlineGroupEdit, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, reorderGroup, reorderHost, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />
</>
);
};

View File

@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const vaultViewLayoutSource = readFileSync(new URL("./vault/VaultViewLayout.tsx", import.meta.url), "utf8");
test("vault stage aligns its content to the top tab bar", () => {
assert.match(vaultViewLayoutSource, /className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0"/);
assert.doesNotMatch(vaultViewLayoutSource, /className="flex min-w-0 flex-1 p-2 pl-0"/);
});

View File

@@ -12,6 +12,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'cursor'
| 'openai'
| 'claude'
| 'anthropic'
@@ -21,6 +22,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'codebuddy'
| 'terminal'
| 'plus';
@@ -41,6 +43,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
cursor: {
src: '/ai/agents/cursor.svg',
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
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',
@@ -86,6 +93,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
codebuddy: {
src: '/ai/agents/codebuddy.svg',
badgeClassName: 'border-indigo-500/22 bg-indigo-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
terminal: {
src: '/ai/agents/terminal.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
@@ -124,6 +136,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('cursor'))) {
return 'cursor';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -159,6 +174,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('factory'))) {
return 'atom';
}
if (tokens.some((token) => token.includes('codebuddy'))) {
return 'codebuddy';
}
return 'terminal';
}

View File

@@ -6,7 +6,9 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { filterQuickMessages, buildSlashCommandItems, filterUserSkillsForSlash, getSlashCommandItemKey, type AIQuickMessage, type SlashCommandItem, type UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
import { SlashCommandPicker } from './SlashCommandPicker';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
@@ -81,6 +83,8 @@ interface ChatInputProps {
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Custom slash prompts configured in Settings → AI */
quickMessages?: AIQuickMessage[];
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
@@ -118,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
hosts = [],
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
@@ -128,7 +133,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
@@ -142,7 +147,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashSkillPicker = activeMenu === 'slashSkill';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
@@ -158,6 +163,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const quickMsgBtnRef = useRef<HTMLButtonElement>(null);
const slashPickerListRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
@@ -202,21 +209,24 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (userSkills.length > 0 && slashTrigger) {
if (slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
if (pos) {
setMenuPos(null);
setInputPanelPos(pos);
}
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashSkill');
setActiveMenu('slashCommand');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashSkillPicker) {
} else if (showSlashCommandPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
@@ -229,22 +239,84 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
setInputPanelPos(pos);
if (menu === 'slashSkill') {
setSlashQuery('');
setSlashRange(null);
if (menu === 'slashCommand') {
const caret = textareaRef.current?.selectionStart ?? value.length;
const trigger = findSlashTrigger(value, caret);
if (trigger) {
setSlashQuery(trigger.query);
setSlashRange({ start: trigger.start, end: trigger.end });
} else {
setSlashQuery('');
setSlashRange(null);
}
}
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
}, [findSlashTrigger, getInputPanelMenuPos, value]);
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
}), [userSkills, slashQuery]);
const openSlashCommandPicker = useCallback((anchor?: 'toolbar') => {
if (anchor === 'toolbar') {
const rect = quickMsgBtnRef.current?.getBoundingClientRect();
if (rect) {
setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
}
setInputPanelPos(null);
const caret = textareaRef.current?.selectionStart ?? value.length;
const trigger = findSlashTrigger(value, caret);
if (trigger) {
setSlashQuery(trigger.query);
setSlashRange({ start: trigger.start, end: trigger.end });
} else {
setSlashQuery('');
setSlashRange(null);
}
setActiveMenu('slashCommand');
return;
}
openInputPanelMenu('slashCommand');
}, [findSlashTrigger, openInputPanelMenu, value]);
const userSkillOptions = useMemo<UserSkillSlashOption[]>(
() => userSkills.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
})),
[userSkills],
);
const quickMessageSlugSet = useMemo(
() => new Set(quickMessages.map((message) => message.slug)),
[quickMessages],
);
const filteredQuickMessages = useMemo(
() => filterQuickMessages(quickMessages, slashQuery),
[quickMessages, slashQuery],
);
const filteredUserSkills = useMemo(
() => filterUserSkillsForSlash(userSkillOptions, slashQuery)
.filter((skill) => !quickMessageSlugSet.has(skill.slug)),
[userSkillOptions, slashQuery, quickMessageSlugSet],
);
const slashCommandItems = useMemo(
() => buildSlashCommandItems(quickMessages, userSkillOptions, slashQuery),
[quickMessages, userSkillOptions, slashQuery],
);
const isSlashCatalogEmpty = quickMessages.length === 0 && userSkills.length === 0;
const slashPickerNoResultsLabel = isSlashCatalogEmpty
? t('ai.chat.slashEmptyHint')
: t('ai.chat.slashNoResults');
const slashPickerListboxId = menuPos ? 'slash-command-toolbar' : 'slash-command-input';
const showSlashPickerUI = showSlashCommandPicker && (inputPanelPos != null || menuPos != null);
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
@@ -264,6 +336,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
const insertQuickMessage = useCallback((message: AIQuickMessage) => {
if (slashRange) {
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
const spacerBefore = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
const spacerAfter = after.length > 0 && !/^\s/.test(after) ? ' ' : '';
onChange(`${before}${spacerBefore}${message.content}${spacerAfter}${after}`);
} else {
const spacer = value.length > 0 && !/\s$/.test(value) ? ' ' : '';
onChange(`${value}${spacer}${message.content}`);
}
closeAllMenus();
}, [closeAllMenus, onChange, slashRange, value]);
const handleSelectSlashCommandItem = useCallback((item: SlashCommandItem) => {
if (item.kind === 'quickMessage') {
insertQuickMessage(item.message);
return;
}
insertUserSkillToken(item.skill);
}, [insertQuickMessage, insertUserSkillToken]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
// filter produces a different set with the same count (e.g. user types
@@ -273,16 +367,64 @@ const ChatInput: React.FC<ChatInputProps> = ({
() => hosts.map((h) => h.sessionId).join('|'),
[hosts],
);
const slashSkillKey = useMemo(
() => filteredUserSkills.map((s) => s.id).join('|'),
[filteredUserSkills],
const slashCommandKey = useMemo(
() => slashCommandItems.map(getSlashCommandItemKey).join('|'),
[slashCommandItems],
);
useEffect(() => {
if (showAtMention) setActiveMenuIndex(0);
}, [showAtMention, atMentionKey]);
useEffect(() => {
if (showSlashSkillPicker) setActiveMenuIndex(0);
}, [showSlashSkillPicker, slashSkillKey]);
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
}, [showSlashCommandPicker, menuPos, slashCommandKey, slashCommandItems.length]);
const handleSlashCommandKeyDown = useCallback((e: KeyboardEvent | React.KeyboardEvent) => {
if ('nativeEvent' in e && e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
if ('shiftKey' in e && e.shiftKey) {
return;
}
if (slashCommandItems.length > 0) {
e.preventDefault();
const item = slashCommandItems[Math.min(activeMenuIndex, slashCommandItems.length - 1)];
if (item) handleSelectSlashCommandItem(item);
return;
}
// Mid-slash token with no matches: block accidental send of "/query" text.
if (slashRange) {
e.preventDefault();
}
return;
}
if (slashCommandItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % slashCommandItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + slashCommandItems.length) % slashCommandItems.length);
return;
}
}, [activeMenuIndex, closeAllMenus, handleSelectSlashCommandItem, slashCommandItems, slashRange]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos) return;
const onKeyDown = (event: KeyboardEvent) => handleSlashCommandKeyDown(event);
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [handleSlashCommandKeyDown, menuPos, showSlashCommandPicker]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
@@ -310,31 +452,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
return;
}
}
// / skill popover keyboard navigation
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
if (skill) insertUserSkillToken(skill);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
// / command popover keyboard navigation (input-anchored picker)
if (showSlashCommandPicker && !menuPos) {
handleSlashCommandKeyDown(e);
return;
}
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
}, [showAtMention, hosts, showSlashCommandPicker, menuPos, activeMenuIndex, handleSelectAtMention, handleSlashCommandKeyDown, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
@@ -585,48 +708,42 @@ const ChatInput: React.FC<ChatInputProps> = ({
document.body,
)}
{/* / skill popover */}
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{filteredUserSkills.map((skill, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`slash-skill-${skill.id}`}
key={skill.id}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => insertUserSkillToken(skill)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>
<SlashCommandPicker
listRef={slashPickerListRef}
listboxId={slashPickerListboxId}
ariaLabel={t('ai.chat.slashCommands')}
quickMessages={filteredQuickMessages}
userSkills={filteredUserSkills}
slashCommandItems={slashCommandItems}
activeMenuIndex={activeMenuIndex}
onActiveIndexChange={setActiveMenuIndex}
onSelectQuickMessage={insertQuickMessage}
onSelectSkill={insertUserSkillToken}
quickMessagesSectionLabel={t('ai.chat.slashQuickMessages')}
userSkillsSectionLabel={t('ai.chat.slashUserSkills')}
noResultsLabel={slashPickerNoResultsLabel}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg outline-none"
style={
menuPos
? {
left: menuPos.left,
bottom: menuPos.bottom,
minWidth: 220,
maxWidth: 360,
}
: {
left: inputPanelPos!.left,
bottom: inputPanelPos!.bottom,
width: 'auto',
minWidth: Math.min(200, inputPanelPos!.width),
maxWidth: inputPanelPos!.width,
}
}
/>
</>,
document.body,
)}
@@ -696,19 +813,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{userSkills.length > 0 && (
<button
type="button"
role="menuitem"
aria-label="Insert user skill"
onClick={() => openInputPanelMenu('slashSkill')}
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"
>
<Package size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
)}
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.slashCommands')}
onClick={() => openInputPanelMenu('slashCommand')}
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"
>
<MessageSquare size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuSlashCommands')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
</div>
</>,
document.body,
@@ -953,6 +1068,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
ref={quickMsgBtnRef}
type="button"
onClick={() => {
if (!showSlashCommandPicker) {
openSlashCommandPicker('toolbar');
} else {
closeAllMenus();
}
}}
className={[
iconButtonClassName,
isSlashCatalogEmpty ? 'opacity-45 hover:opacity-80' : '',
].filter(Boolean).join(' ')}
aria-label={t('ai.chat.slashCommands')}
aria-expanded={showSlashCommandPicker}
>
<MessageSquare size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.slashCommands')}</TooltipContent>
</Tooltip>
<PromptInputSubmit
status={status}
onStop={onStop}

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import ChatMessageList from "./ChatMessageList.tsx";
const makeMessage = (index: number): ChatMessage => ({
id: `msg-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index}`,
timestamp: index,
});
test("ChatMessageList only renders the recent message batch by default", () => {
const messages = Array.from({ length: 60 }, (_value, index) => makeMessage(index));
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ChatMessageList, { messages }),
),
);
assert.match(markup, /Load earlier messages \(10 more\)/);
assert.doesNotMatch(markup, /message-0/);
assert.match(markup, /message-10/);
assert.match(markup, /message-59/);
});

View File

@@ -19,6 +19,7 @@ import {
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import ThinkingBlock from './ThinkingBlock';
import ToolCallGroup from './ToolCallGroup';
import {
onApprovalRequest,
onApprovalCleared,
@@ -208,13 +209,34 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
</button>
)}
{displayedMessages.map((message) => {
{displayedMessages.map((message, idx) => {
if (message.role === 'tool') {
if (hideToolCalls) return null;
// Group consecutive tool messages into a collapsible section
// Skip if this is NOT the first in a consecutive run
const prevIsTool = idx > 0 && displayedMessages[idx - 1].role === "tool";
if (prevIsTool || hideToolCalls) return null;
// Collect this run of consecutive tool messages
let end = idx + 1;
while (end < displayedMessages.length && displayedMessages[end].role === "tool") end++;
const group = displayedMessages.slice(idx, end);
const groupTotal = group.reduce(
(sum, m) => sum + (m.toolResults?.length ?? 0), 0,
);
// Expanded while the agent is still working (no assistant response follows)
const hasAssistantAfter = end < displayedMessages.length
&& displayedMessages[end].role === "assistant";
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Result')}>
<ToolCallGroup
key={`tool-group-${message.id}`}
count={groupTotal}
defaultExpanded={!hasAssistantAfter}
>
{group.map((toolMsg) =>
toolMsg.toolResults?.map((tr) => (
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps("AIChatPanel.ToolCall.Result")}>
<div>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
@@ -223,9 +245,10 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
isError={tr.isError}
/>
</div>
</React.Profiler>
))}
</React.Fragment>
</React.Profiler>
)),
)}
</ToolCallGroup>
);
}
@@ -296,33 +319,39 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{!hideToolCalls && (message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
{(() => {
if (hideToolCalls) return null;
if (message === lastAssistantMessage && message.executionStatus !== "cancelled") return null;
const unresolvedTcs = message.toolCalls?.filter((tc) => !resolvedToolCallIds.has(tc.id)) ?? [];
if (unresolvedTcs.length === 0) return null;
return (
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Pending')}>
<div>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
</React.Profiler>
<ToolCallGroup count={unresolvedTcs.length} defaultExpanded={false}>
{unresolvedTcs.map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? "pending" as const
: resolved === true
? "approved" as const
: resolved === false
? "denied" as const
: undefined;
return (
<div key={tc.id} className="px-2 py-1.5">
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
</ToolCallGroup>
);
})}
})()}
{/* Status text with shimmer */}
{message.statusText && (
@@ -352,33 +381,42 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{!hideToolCalls && lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
{(() => {
if (hideToolCalls) return null;
const pendingTcs = lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== "cancelled",
) ?? [];
if (pendingTcs.length === 0) return null;
const isActive = lastAssistantMessage.executionStatus !== "error";
const isToolRunning = !!(isStreaming && lastAssistantMessage.executionStatus === "running");
return (
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Last')}>
<div>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
</React.Profiler>
<ToolCallGroup count={pendingTcs.length} defaultExpanded={isActive}>
{pendingTcs.map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? "pending" as const
: resolved === true
? "approved" as const
: resolved === false
? "denied" as const
: undefined;
return (
<div key={tc.id} className="px-2 py-1.5">
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isToolRunning && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
</ToolCallGroup>
);
})}
})()}
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
{!hideToolCalls && Array.from(pendingApprovals.entries())

View File

@@ -0,0 +1,146 @@
import { MessageSquare, Package } from 'lucide-react';
import React from 'react';
import type { AIQuickMessage, SlashCommandItem, UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
import { getSlashCommandItemId } from '../../infrastructure/ai/quickMessages';
import { ScrollArea } from '../ui/scroll-area';
export interface SlashCommandPickerProps {
listboxId: string;
ariaLabel: string;
quickMessages: AIQuickMessage[];
userSkills: UserSkillSlashOption[];
slashCommandItems: SlashCommandItem[];
activeMenuIndex: number;
onActiveIndexChange: (index: number) => void;
onSelectQuickMessage: (message: AIQuickMessage) => void;
onSelectSkill: (skill: UserSkillSlashOption) => void;
quickMessagesSectionLabel: string;
userSkillsSectionLabel: string;
noResultsLabel: string;
emptyHintLabel?: string;
className?: string;
style?: React.CSSProperties;
listRef?: React.Ref<HTMLDivElement>;
}
export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
listboxId,
ariaLabel,
quickMessages,
userSkills,
slashCommandItems,
activeMenuIndex,
onActiveIndexChange,
onSelectQuickMessage,
onSelectSkill,
quickMessagesSectionLabel,
userSkillsSectionLabel,
noResultsLabel,
emptyHintLabel,
className,
style,
listRef,
}) => {
const activeItem = slashCommandItems[activeMenuIndex];
const activeDescendantId = activeItem ? `${listboxId}-${getSlashCommandItemId(activeItem)}` : undefined;
return (
<div
ref={listRef}
id={listboxId}
role="listbox"
tabIndex={-1}
aria-label={ariaLabel}
aria-activedescendant={activeDescendantId}
className={className}
style={style}
>
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{slashCommandItems.length === 0 ? (
<div className="px-3 py-4 text-center space-y-1">
<p className="text-[12px] text-muted-foreground/70">{noResultsLabel}</p>
{emptyHintLabel ? (
<p className="text-[11px] text-muted-foreground/45 leading-relaxed">{emptyHintLabel}</p>
) : null}
</div>
) : (
<>
{quickMessages.length > 0 ? (
<>
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
{quickMessagesSectionLabel}
</div>
{quickMessages.map((message) => {
const idx = slashCommandItems.findIndex(
(item) => item.kind === 'quickMessage' && item.message.id === message.id,
);
const isActive = idx === activeMenuIndex;
return (
<button
id={`${listboxId}-${message.id}`}
key={message.id}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => onActiveIndexChange(idx)}
onClick={() => onSelectQuickMessage(message)}
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] min-w-0">
<MessageSquare size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90 truncate">{message.name}</span>
<span className="text-muted-foreground/45 font-mono shrink-0">/{message.slug}</span>
</div>
{(message.description || message.content) ? (
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{message.description || message.content}
</div>
) : null}
</button>
);
})}
</>
) : null}
{userSkills.length > 0 ? (
<>
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
{userSkillsSectionLabel}
</div>
{userSkills.map((skill) => {
const idx = slashCommandItems.findIndex(
(item) => item.kind === 'skill' && item.skill.id === skill.id,
);
const isActive = idx === activeMenuIndex;
return (
<button
id={`${listboxId}-${skill.id}`}
key={skill.id}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => onActiveIndexChange(idx)}
onClick={() => onSelectSkill(skill)}
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
);
})}
</>
) : null}
</>
)}
</div>
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,65 @@
/**
* ToolCallGroup - Collapsible container for grouped tool calls.
*
* Groups consecutive tool-call messages into a single collapsible section
* (Codex-style). While the agent is still working the group stays expanded;
* once the assistant responds it auto-collapses to "Used N tools".
*/
import { ChevronDown, ChevronRight } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
interface ToolCallGroupProps {
count: number;
children: React.ReactNode;
/** When true the group starts expanded (e.g. while streaming). */
defaultExpanded?: boolean;
}
const ToolCallGroup: React.FC<ToolCallGroupProps> = ({
count,
children,
defaultExpanded = false,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(defaultExpanded);
const prevDefault = useRef(defaultExpanded);
// Auto-collapse when the group transitions from "active" to "resolved"
useEffect(() => {
if (prevDefault.current && !defaultExpanded) {
setExpanded(false);
}
prevDefault.current = defaultExpanded;
}, [defaultExpanded]);
return (
<div className="rounded-md border border-border/20 bg-muted/5 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer',
'hover:bg-muted/20 transition-colors select-none',
)}
>
{expanded
? <ChevronDown size={12} className="text-muted-foreground/50 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
}
<span className="text-muted-foreground/70 font-medium">
{t('ai.chat.usedTools', { n: count })}
</span>
</button>
{expanded && (
<div className="border-t border-border/20 p-1.5 space-y-1.5">
{children}
</div>
)}
</div>
);
};
export default ToolCallGroup;

View File

@@ -25,6 +25,14 @@ const agents: ExternalAgentConfig[] = [
command: '/usr/local/bin/missing-backend-agent',
enabled: true,
},
{
id: 'unavailable-agent',
name: 'Unavailable Agent',
command: '/usr/local/bin/unavailable-agent',
sdkBackend: 'cursor',
enabled: true,
available: false,
},
];
test('canSendWithAgent allows Catty and enabled external agents', () => {
@@ -35,6 +43,7 @@ test('canSendWithAgent allows Catty and enabled external agents', () => {
test('canSendWithAgent blocks missing or disabled external agents', () => {
assert.equal(canSendWithAgent('disabled-agent', agents), false);
assert.equal(canSendWithAgent('missing-backend-agent', agents), false);
assert.equal(canSendWithAgent('unavailable-agent', agents), false);
assert.equal(canSendWithAgent('missing-agent', agents), false);
});

View File

@@ -5,7 +5,11 @@ export function findEnabledExternalAgent(
agents: ExternalAgentConfig[],
agentId: string,
): ExternalAgentConfig | undefined {
return agents.find((agent) => agent.id === agentId && agent.enabled && Boolean(getExternalAgentSdkBackend(agent)));
return agents.find((agent) =>
agent.id === agentId &&
agent.enabled &&
agent.available !== false &&
Boolean(getExternalAgentSdkBackend(agent)));
}
export function canSendWithAgent(

View File

@@ -9,6 +9,7 @@ import {
applyDraftEntrySelection,
applyHistorySessionSelection,
normalizePanelView,
panelViewsEqual,
resolveDisplayedPanelView,
resolveDisplayedSession,
} from "./aiPanelViewState.ts";
@@ -28,6 +29,23 @@ function createSession(id: string): AISession {
};
}
test("panelViewsEqual treats draft views as equal even when refs differ", () => {
assert.equal(
panelViewsEqual({ mode: "draft" }, { mode: "draft" }),
true,
);
});
test("panelViewsEqual distinguishes session targets", () => {
assert.equal(
panelViewsEqual(
{ mode: "session", sessionId: "session-1" },
{ mode: "session", sessionId: "session-2" },
),
false,
);
});
test("draft view never falls back to most recent history", () => {
const panelView: AIPanelView = { mode: "draft" };
const sessions = [createSession("session-2"), createSession("session-1")];

View File

@@ -5,6 +5,22 @@ import type {
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
export function panelViewsEqual(
left: AIPanelView,
right: AIPanelView,
): boolean {
if (left === right) {
return true;
}
if (left.mode !== right.mode) {
return false;
}
if (left.mode === "session" && right.mode === "session") {
return left.sessionId === right.sessionId;
}
return true;
}
interface HistorySessionSelectionActions {
showSessionView: (sessionId: string) => void;
setActiveSessionId: (sessionId: string) => void;

View File

@@ -1,7 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
import {
buildManagedAgentState,
updateCodebuddyManagedEnv,
} from '../settings/tabs/ai/managedAgentState';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
@@ -101,6 +104,123 @@ test('buildManagedAgentState stores SDK backend keys for discovered managed agen
assert.equal(copilotState.agents[0].acpArgs, undefined);
});
test('buildManagedAgentState stores SDK backend key for discovered Cursor', () => {
const state = buildManagedAgentState(
[],
'catty',
'cursor',
{ path: 'cursor', version: 'Cursor SDK 1.0.18', available: true },
);
assert.equal(state.agents[0].id, 'discovered_cursor');
assert.equal(state.agents[0].name, 'Cursor');
assert.equal(state.agents[0].command, 'cursor');
assert.equal(state.agents[0].sdkBackend, 'cursor');
});
test('buildManagedAgentState preserves a saved Cursor API key when SDK is not ready', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_cursor',
name: 'Cursor',
command: 'cursor',
enabled: true,
available: true,
sdkBackend: 'cursor',
apiKey: 'enc:v1:test',
},
];
const state = buildManagedAgentState(
agents,
'discovered_cursor',
'cursor',
{ path: 'cursor', version: 'Cursor SDK', available: false, installed: true },
);
assert.equal(state.agents[0].id, 'discovered_cursor');
assert.equal(state.agents[0].apiKey, 'enc:v1:test');
assert.equal(state.agents[0].enabled, false);
assert.equal(state.agents[0].available, false);
assert.equal(state.defaultAgentId, 'catty');
});
test('buildManagedAgentState stores CODEBUDDY_CODE_PATH for codebuddy', () => {
const state = buildManagedAgentState(
[],
'catty',
'codebuddy',
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
assert.equal(state.agents[0].sdkBackend, 'codebuddy');
assert.deepEqual(state.agents[0].env, {
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
});
});
test('updateCodebuddyManagedEnv creates a disabled managed entry before CLI detection', () => {
const state = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
assert.equal(state.length, 1);
assert.equal(state[0].id, 'discovered_codebuddy');
assert.equal(state[0].command, 'codebuddy');
assert.equal(state[0].enabled, false);
assert.deepEqual(state[0].env, {
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
CODEBUDDY_API_KEY: 'secret',
});
});
test('buildManagedAgentState preserves disabled CodeBuddy config when path detection fails', () => {
const agents = updateCodebuddyManagedEnv([], 'ioa', 'CODEBUDDY_AUTH_TOKEN=token');
const state = buildManagedAgentState(
agents,
'discovered_codebuddy',
'codebuddy',
{ path: null, version: null, available: false },
);
assert.equal(state.defaultAgentId, 'catty');
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].id, 'discovered_codebuddy');
assert.equal(state.agents[0].enabled, false);
assert.deepEqual(state.agents[0].env, {
CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa',
CODEBUDDY_AUTH_TOKEN: 'token',
});
});
test('buildManagedAgentState enables preconfigured CodeBuddy when path detection succeeds', () => {
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
const state = buildManagedAgentState(
agents,
'catty',
'codebuddy',
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].enabled, true);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
assert.deepEqual(state.agents[0].env, {
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
CODEBUDDY_API_KEY: 'secret',
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
});
});
test('updateCodebuddyManagedEnv removes an empty pre-detection placeholder', () => {
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
const cleared = updateCodebuddyManagedEnv(agents, '', '');
assert.deepEqual(cleared, []);
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{

View File

@@ -14,6 +14,7 @@ interface IdentityCardProps {
identity: Identity;
viewMode: 'grid' | 'list';
isSelected: boolean;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onClick: () => void;
}
@@ -21,6 +22,7 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
identity,
viewMode,
isSelected,
reorderProps,
onClick,
}) => {
const { t } = useI18n();
@@ -43,12 +45,15 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
return (
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary"
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>

View File

@@ -27,6 +27,7 @@ interface KeyCardProps {
viewMode: 'grid' | 'list';
isSelected: boolean;
isMac: boolean;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onClick: () => void;
onEdit: () => void;
onExport: () => void;
@@ -39,6 +40,7 @@ export const KeyCard: React.FC<KeyCardProps> = ({
viewMode,
isSelected,
isMac,
reorderProps,
onClick,
onEdit,
onExport,
@@ -50,12 +52,15 @@ export const KeyCard: React.FC<KeyCardProps> = ({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary"
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>

View File

@@ -21,6 +21,7 @@ export interface RuleCardProps {
viewMode: ViewMode;
isSelected: boolean;
isPending: boolean;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onSelect: () => void;
onEdit: () => void;
onDuplicate: () => void;
@@ -35,6 +36,7 @@ export const RuleCard: React.FC<RuleCardProps> = ({
viewMode,
isSelected,
isPending,
reorderProps,
onSelect,
onEdit,
onDuplicate,
@@ -50,12 +52,15 @@ export const RuleCard: React.FC<RuleCardProps> = ({
<ContextMenu>
<ContextMenuTrigger>
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary"
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onSelect}
>

View File

@@ -3,8 +3,8 @@
*
* Sub-components live in ./ai/ directory:
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
* - ModelSelector, ProviderIconBadge
* - CodexConnectionCard, ClaudeCodeCard
* - ModelSelector
* - CodexConnectionCard, ClaudeCodeCard, CodebuddyCard
* - SafetySettings
*/
import { AlertTriangle, Bot, FolderOpen, RefreshCcw } from "lucide-react";
@@ -35,20 +35,26 @@ import {
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 { CopilotCliCard } from "./ai/CopilotCliCard";
import { CodebuddyCard } from "./ai/CodebuddyCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
import { QuickMessagesSettings } from "./ai/QuickMessagesSettings";
import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages";
import { encryptField } from "../../../infrastructure/persistence/secureFieldAdapter";
import { CursorSdkCard } from "./ai/CursorSdkCard";
import {
areExternalAgentListsEqual,
buildManagedAgentState,
getInitialManagedAgentPaths,
updateCodebuddyManagedEnv,
} from "./ai/managedAgentState";
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
// ---------------------------------------------------------------------------
// Props
@@ -79,6 +85,8 @@ interface SettingsAITabProps {
setMaxIterations: (value: number) => void;
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
}
// ---------------------------------------------------------------------------
@@ -110,6 +118,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -154,6 +164,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codex: string;
claude: string;
copilot: string;
cursor: string;
codebuddy: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
@@ -162,8 +174,37 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(null);
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(null);
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState("");
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
const codebuddyManagedEnv = useMemo(
() => externalAgents.find((a) => a.id === "discovered_codebuddy")?.env,
[externalAgents],
);
const {
internetEnv: codebuddyInternetEnv,
envText: codebuddyEnvText,
} = useMemo(() => splitCodebuddyEnv(codebuddyManagedEnv), [codebuddyManagedEnv]);
const updateCodebuddyEnv = useCallback(
(nextInternetEnv: string, nextEnvText: string) => {
setExternalAgents((prev) =>
updateCodebuddyManagedEnv(prev, nextInternetEnv, nextEnvText),
);
},
[setExternalAgents],
);
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
const cursorManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_cursor"),
[externalAgents],
);
const cursorApiKeyEncrypted = cursorManagedAgent?.apiKey;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
@@ -172,6 +213,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
options?: { apiKeyPresent?: boolean },
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return null;
@@ -180,18 +222,28 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
: agentKey === "copilot"
? setCopilotPathInfo
: agentKey === "cursor"
? setCursorPathInfo
: setCodebuddyPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
: agentKey === "copilot"
? setIsResolvingCopilot
: agentKey === "cursor"
? setIsResolvingCursor
: setIsResolvingCodebuddy;
setResolving(true);
try {
const result = await bridge.aiResolveCli({
command: agentKey,
customPath: customPath.trim(),
refreshShellEnv: agentKey === "cursor",
...(agentKey === "cursor" ? { apiKeyPresent: Boolean(options?.apiKeyPresent ?? cursorApiKeyEncrypted) } : {}),
});
setInfo(result);
@@ -220,13 +272,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
} finally {
setResolving(false);
}
}, [setExternalAgents, setDefaultAgentId]);
}, [cursorApiKeyEncrypted, setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
void resolveAgentPath("cursor", initialManagedPathsRef.current?.cursor ?? "", { apiKeyPresent: Boolean(cursorApiKeyEncrypted) });
void resolveAgentPath("codebuddy", initialManagedPathsRef.current?.codebuddy ?? "");
}, [cursorApiKeyEncrypted, resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
@@ -234,9 +288,41 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
: agentKey === "copilot"
? copilotCustomPath
: agentKey === "codebuddy"
? codebuddyCustomPath
: "";
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, resolveAgentPath]);
const handleSaveCursorApiKey = useCallback(async (apiKey: string) => {
const trimmed = apiKey.trim();
const encrypted = trimmed ? await encryptField(trimmed) : undefined;
const result = await resolveAgentPath("cursor", "", { apiKeyPresent: Boolean(trimmed) });
setExternalAgents((prev) => {
const existing = prev.find((agent) => agent.id === "discovered_cursor");
const others = prev.filter((agent) => agent.id !== "discovered_cursor");
if (!encrypted && !existing) return prev;
if (!encrypted && existing && !result?.available) return others;
const nextAgent: ExternalAgentConfig = {
...(existing ?? {
id: "discovered_cursor",
name: "Cursor",
command: result?.path || cursorPathInfo?.path || "cursor",
args: ["{prompt}"],
icon: "cursor",
sdkBackend: "cursor",
enabled: false,
}),
apiKey: encrypted,
command: result?.path || existing?.command || cursorPathInfo?.path || "cursor",
available: Boolean(result?.available),
enabled: result?.available ? (existing?.enabled ?? true) : false,
};
return [...others, nextAgent];
});
}, [cursorPathInfo?.path, resolveAgentPath, setExternalAgents]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -436,6 +522,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
};
}, [refreshUserSkillsStatus]);
const reservedUserSkillSlugs = useMemo(
() => (userSkillsStatus?.ok && userSkillsStatus.skills
? userSkillsStatus.skills
.filter((skill) => skill.status === 'ready' && typeof skill.slug === 'string' && skill.slug.length > 0)
.map((skill) => skill.slug)
: []),
[userSkillsStatus],
);
const handleOpenUserSkillsFolder = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsOpenFolder) return;
@@ -518,7 +613,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.codex')}
leading={<ProviderIconBadge providerId="openai" size="sm" />}
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CodexConnectionCard
pathInfo={codexPathInfo}
@@ -540,7 +635,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.claude.title')}
leading={<ProviderIconBadge providerId="claude" size="sm" />}
leading={<AgentIconBadge agent={{ id: "claude", icon: "claude", name: "Claude Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<ClaudeCodeCard
pathInfo={claudePathInfo}
@@ -559,7 +654,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.copilot.title')}
leading={<ProviderIconBadge providerId="copilot" size="sm" />}
leading={<AgentIconBadge agent={{ id: "copilot", icon: "copilot", name: "GitHub Copilot CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CopilotCliCard
pathInfo={copilotPathInfo}
@@ -570,6 +665,36 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
/>
</SettingsSection>
<SettingsSection
title={t('ai.cursor.title')}
leading={<AgentIconBadge agent={{ id: "cursor", icon: "cursor", name: "Cursor" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CursorSdkCard
pathInfo={cursorPathInfo}
isResolvingPath={isResolvingCursor}
encryptedApiKey={cursorApiKeyEncrypted}
onSaveApiKey={handleSaveCursorApiKey}
onRecheckPath={() => void handleCheckCustomPath("cursor")}
/>
</SettingsSection>
<SettingsSection
title={t('ai.codebuddy.title')}
leading={<AgentIconBadge agent={{ id: "codebuddy", icon: "codebuddy", name: "CodeBuddy Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CodebuddyCard
pathInfo={codebuddyPathInfo}
isResolvingPath={isResolvingCodebuddy}
customPath={codebuddyCustomPath}
onCustomPathChange={setCodebuddyCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codebuddy")}
internetEnv={codebuddyInternetEnv}
onInternetEnvChange={(v) => updateCodebuddyEnv(v, codebuddyEnvText)}
envText={codebuddyEnvText}
onEnvTextChange={(v) => updateCodebuddyEnv(codebuddyInternetEnv, v)}
/>
</SettingsSection>
{agentOptions.length > 1 && (
<SettingsSection title={t('ai.defaultAgent')}>
<SettingCard>
@@ -626,20 +751,20 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</>
)}
>
<SettingCard padded className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
<SettingCard padded className="space-y-3">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground/80 leading-5">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground/80">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
@@ -651,25 +776,25 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
<div className="border-t border-border/60 divide-y divide-border/60">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
<div className="text-sm font-medium">{skill.name}</div>
<div className="text-xs text-muted-foreground leading-5">{skill.description}</div>
<div className="text-xs text-muted-foreground/80 font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
? "text-xs font-medium text-emerald-500 shrink-0"
: "text-xs font-medium text-amber-500 shrink-0"
}
>
{skill.status === "ready"
@@ -678,7 +803,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
<div className="mt-2 space-y-1 text-xs text-amber-500">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
@@ -691,13 +816,19 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}
</SettingCard>
</SettingsSection>
<QuickMessagesSettings
quickMessages={quickMessages}
setQuickMessages={setQuickMessages}
reservedUserSkillSlugs={reservedUserSkillSlugs}
/>
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}

View File

@@ -5,11 +5,13 @@ import { keyEventToString } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { SectionHeader, Select, SettingsTabContent, SettingRow } from "../settings-ui";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
export default function SettingsShortcutsTab(props: {
hotkeyScheme: HotkeyScheme;
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
keyBindings: KeyBinding[];
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
@@ -19,6 +21,8 @@ export default function SettingsShortcutsTab(props: {
const {
hotkeyScheme,
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
keyBindings,
updateKeyBinding,
resetKeyBinding,
@@ -136,6 +140,15 @@ export default function SettingsShortcutsTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
>
<Toggle
checked={shellOnlyTabNumberShortcuts}
onChange={setShellOnlyTabNumberShortcuts}
/>
</SettingRow>
</div>
{hotkeyScheme !== "disabled" && (

View File

@@ -856,6 +856,94 @@ function SettingsTerminalTab(props: {
)}
</div>
<SectionHeader title={t("settings.terminal.section.systemManager")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.systemManager.processRefreshInterval")}
description={t("settings.terminal.systemManager.processRefreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={2}
max={60}
value={terminalSettings.systemManagerProcessRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 3;
if (val >= 2 && val <= 60) {
updateTerminalSetting("systemManagerProcessRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
<SettingRow
label={t("settings.terminal.systemManager.tmuxRefreshInterval")}
description={t("settings.terminal.systemManager.tmuxRefreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={2}
max={60}
value={terminalSettings.systemManagerTmuxRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 3;
if (val >= 2 && val <= 60) {
updateTerminalSetting("systemManagerTmuxRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
<SettingRow
label={t("settings.terminal.systemManager.dockerListRefreshInterval")}
description={t("settings.terminal.systemManager.dockerListRefreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={3}
max={120}
value={terminalSettings.systemManagerDockerListRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 5;
if (val >= 3 && val <= 120) {
updateTerminalSetting("systemManagerDockerListRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
<SettingRow
label={t("settings.terminal.systemManager.dockerStatsRefreshInterval")}
description={t("settings.terminal.systemManager.dockerStatsRefreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={2}
max={60}
value={terminalSettings.systemManagerDockerStatsRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 3;
if (val >= 2 && val <= 60) {
updateTerminalSetting("systemManagerDockerStatsRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.rendering")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
@@ -873,15 +961,6 @@ function SettingsTerminalTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.rendering.lineTimestamps")}
description={t("settings.terminal.rendering.lineTimestamps.desc")}
>
<Toggle
checked={terminalSettings.showLineTimestamps}
onChange={(v) => updateTerminalSetting("showLineTimestamps", v)}
/>
</SettingRow>
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, 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 { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.internetEnv.default" },
{ value: "internal", labelKey: "ai.codebuddy.internetEnv.internal" },
{ value: "ioa", labelKey: "ai.codebuddy.internetEnv.ioa" },
] as const;
export const CodebuddyCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
internetEnv: string;
onInternetEnvChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
internetEnv,
onInternetEnvChange,
envText,
onEnvTextChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(internetEnv.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.codebuddy.detecting')
: found
? t('ai.codebuddy.detected')
: t('ai.codebuddy.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codebuddy.description')}
</p>
<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.codebuddy.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.codebuddy.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.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.codebuddy.check')}
</Button>
</div>
</div>
) : null}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.codebuddy.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="codebuddy-internet-env" className="text-xs text-muted-foreground">{t('ai.codebuddy.internetEnv')}</label>
<select
id="codebuddy-internet-env"
value={internetEnv}
onChange={(e) => onInternetEnvChange(e.target.value)}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{INTERNET_ENV_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.internetEnv.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="codebuddy-env-vars" className="text-xs text-muted-foreground">{t('ai.codebuddy.envVars')}</label>
<textarea
id="codebuddy-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.codebuddy.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CopilotCliCard } from "./CopilotCliCard";
function firstButton(markup: string): string {
const match = markup.match(/<button\b[^>]*>/);
return match?.[0] ?? "";
}
test("Cursor check button stays enabled without a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.cursor"
allowEmptyCheck
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), false);
});
test("Copilot check button still requires a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
});

View File

@@ -11,21 +11,27 @@ export const CopilotCliCard: React.FC<{
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
i18nPrefix?: "ai.copilot" | "ai.cursor";
allowEmptyCheck?: boolean;
showCustomPathInput?: boolean;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
i18nPrefix = "ai.copilot",
allowEmptyCheck = false,
showCustomPathInput = true,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const statusText = isResolvingPath
? t('ai.copilot.detecting')
? t(`${i18nPrefix}.detecting`)
: found
? t('ai.copilot.detected')
: t('ai.copilot.notFound');
? t(`${i18nPrefix}.detected`)
: t(`${i18nPrefix}.notFound`);
const statusClassName = isResolvingPath
? "text-muted-foreground"
@@ -37,7 +43,7 @@ export const CopilotCliCard: React.FC<{
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.copilot.description')}
{t(`${i18nPrefix}.description`)}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
@@ -46,7 +52,7 @@ export const CopilotCliCard: React.FC<{
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
@@ -58,19 +64,21 @@ export const CopilotCliCard: React.FC<{
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.copilot.notFoundHint')}
{t(`${i18nPrefix}.notFoundHint`)}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.copilot.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<div className={cn("flex items-center gap-2", showCustomPathInput ? "" : "justify-end")}>
{showCustomPathInput && (
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.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={!allowEmptyCheck && !customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.copilot.check')}
{t(`${i18nPrefix}.check`)}
</Button>
</div>
</div>

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from "react";
import { Check, Eye, EyeOff, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
export const CursorSdkCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
encryptedApiKey?: string;
onSaveApiKey: (apiKey: string) => Promise<void>;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
encryptedApiKey,
onSaveApiKey,
onRecheckPath,
}) => {
const { t } = useI18n();
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
let cancelled = false;
setSaved(false);
if (!encryptedApiKey) {
setApiKeyDraft("");
return;
}
setIsDecrypting(true);
decryptField(encryptedApiKey)
.then((value) => {
if (!cancelled) setApiKeyDraft(value ?? "");
})
.catch(() => {
if (!cancelled) setApiKeyDraft("");
})
.finally(() => {
if (!cancelled) setIsDecrypting(false);
});
return () => {
cancelled = true;
};
}, [encryptedApiKey]);
const installed = Boolean(pathInfo?.installed);
const available = Boolean(pathInfo?.available);
const hasStoredApiKey = Boolean(encryptedApiKey);
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY";
const hasAnyApiKey = hasStoredApiKey || usesEnvApiKey;
const canSave = !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
const installStatus = isResolvingPath
? t("ai.cursor.detecting")
: installed
? t("ai.cursor.installed")
: t("ai.cursor.notInstalled");
const keyStatus = hasAnyApiKey
? usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyFromEnv")
: t("ai.cursor.apiKeyConfigured")
: t("ai.cursor.apiKeyMissing");
const installStatusClassName = isResolvingPath
? "text-muted-foreground"
: installed
? "text-emerald-500"
: "text-amber-500";
const keyStatusClassName = hasAnyApiKey ? "text-emerald-500" : "text-amber-500";
const handleSave = async () => {
setIsSaving(true);
setSaved(false);
try {
await onSaveApiKey(apiKeyDraft.trim());
setSaved(true);
} finally {
setIsSaving(false);
}
};
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="grid gap-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("ai.cursor.installStatus")}</span>
<span className={cn("font-medium", installStatusClassName)}>{installStatus}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("ai.cursor.apiKeyStatus")}</span>
<span className={cn("font-medium", keyStatusClassName)}>{keyStatus}</span>
</div>
</div>
{!available && (
<p className="text-xs text-amber-500">
{installed ? t("ai.cursor.notFoundHint") : t("ai.cursor.notInstalledHint")}
</p>
)}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t("ai.cursor.apiKey")}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyDraft}
onChange={(event) => {
setSaved(false);
setApiKeyDraft(event.target.value);
}}
placeholder={
isDecrypting
? t("ai.providers.apiKey.decrypting")
: usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyPlaceholder.env")
: t("ai.cursor.apiKeyPlaceholder")
}
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((value) => !value)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showApiKey ? t("ai.cursor.hideApiKey") : t("ai.cursor.showApiKey")}
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
{saved ? <Check size={14} className="mr-1.5" /> : null}
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
</Button>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
<RefreshCw size={14} className="mr-1.5" />
{t("ai.cursor.check")}
</Button>
</div>
{usesEnvApiKey && !hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyEnvHint")}
</p>
) : null}
{usesEnvApiKey && hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyOverrideHint")}
</p>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,304 @@
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
import {
createQuickMessageId,
isValidQuickMessageSlug,
normalizeQuickMessageSlug,
QUICK_MESSAGE_LIMITS,
slugFromQuickMessageName,
} from "../../../../infrastructure/ai/quickMessages";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { SettingCard, SettingsSection } from "../../settings-ui";
interface QuickMessagesSettingsProps {
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
reservedUserSkillSlugs?: string[];
}
type DraftQuickMessage = {
name: string;
slug: string;
content: string;
description: string;
};
const emptyDraft = (): DraftQuickMessage => ({
name: "",
slug: "",
content: "",
description: "",
});
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
quickMessages,
setQuickMessages,
reservedUserSkillSlugs = [],
}) => {
const { t } = useI18n();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
const [slugTouched, setSlugTouched] = useState(false);
const [error, setError] = useState<string | null>(null);
const sortedMessages = useMemo(
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
[quickMessages],
);
const resetEditor = useCallback(() => {
setEditingId(null);
setIsCreating(false);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginCreate = useCallback(() => {
setEditingId(null);
setIsCreating(true);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginEdit = useCallback((message: AIQuickMessage) => {
setIsCreating(false);
setEditingId(message.id);
setDraft({
name: message.name,
slug: message.slug,
content: message.content,
description: message.description ?? "",
});
setSlugTouched(true);
setError(null);
}, []);
const handleNameChange = useCallback((name: string) => {
setDraft((prev) => ({
...prev,
name,
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
}));
}, [slugTouched]);
const handleSlugChange = useCallback((slug: string) => {
setSlugTouched(true);
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
}, []);
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
const name = nextDraft.name.trim();
const slug = normalizeQuickMessageSlug(nextDraft.slug);
const content = nextDraft.content.trim();
if (!name) return t("ai.quickMessages.error.nameRequired");
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
if (!content) return t("ai.quickMessages.error.contentRequired");
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
}
const slugTaken = quickMessages.some(
(message) => message.slug === slug && message.id !== excludeId,
);
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
if (skillConflict) {
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
}
return null;
}, [quickMessages, reservedUserSkillSlugs, t]);
const handleSave = useCallback(() => {
const validationError = validateDraft(draft, editingId);
if (validationError) {
setError(validationError);
return;
}
const payload: AIQuickMessage = {
id: editingId ?? createQuickMessageId(),
name: draft.name.trim(),
slug: normalizeQuickMessageSlug(draft.slug),
content: draft.content.trim(),
description: draft.description.trim() || undefined,
};
if (editingId) {
setQuickMessages((prev) => prev.map((message) => (
message.id === editingId ? payload : message
)));
} else {
setQuickMessages((prev) => [...prev, payload]);
}
resetEditor();
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
const handleDelete = useCallback((message: AIQuickMessage) => {
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
if (!ok) return;
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
if (editingId === message.id) {
resetEditor();
}
}, [editingId, resetEditor, setQuickMessages, t]);
const showEditor = isCreating || editingId != null;
return (
<SettingsSection
title={t("ai.quickMessages.title")}
actions={(
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
<Plus size={14} className="mr-2" />
{t("ai.quickMessages.add")}
</Button>
)}
>
<SettingCard padded className="space-y-3">
<p className="text-xs text-muted-foreground/80 leading-5">
{t("ai.quickMessages.description")}
</p>
{showEditor ? (
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
</div>
<button
type="button"
onClick={resetEditor}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
aria-label={t("common.cancel")}
>
<X size={14} />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
<input
value={draft.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("ai.quickMessages.name.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.name}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground/70">/</span>
<input
value={draft.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder={t("ai.quickMessages.slug.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.slug}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
/>
</div>
</label>
</div>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
<input
value={draft.description}
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.description}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
<textarea
value={draft.content}
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
placeholder={t("ai.quickMessages.content.placeholder")}
rows={5}
maxLength={QUICK_MESSAGE_LIMITS.content}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
/>
</label>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={resetEditor}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("common.save")}
</Button>
</div>
</div>
) : null}
{sortedMessages.length > 0 ? (
<div className="border-t border-border/60 divide-y divide-border/60">
{sortedMessages.map((message) => (
<div
key={message.id}
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{message.name}</span>
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
</div>
{message.description ? (
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
) : null}
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
{message.content}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => beginEdit(message)}
aria-label={t("ai.quickMessages.editTitle")}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(message)}
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showEditor ? (
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
</div>
) : null}
</SettingCard>
</SettingsSection>
);
};

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