Compare commits

...

11 Commits

Author SHA1 Message Date
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
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 / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
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 / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

* feat(shortcuts): Ctrl+W closes the tab directly (drop side-panel priority)

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

* feat(shortcuts): register toggle-side-panel binding (default ⌘/Ctrl+\)

* feat(shortcuts): add side-panel toggle handler + last-panel memory in TerminalLayer

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫
6e9e8fc40d feat(autocomplete): make snippets a first-class terminal completion source (#1097)
* feat(autocomplete): add snippet completion source

* feat(autocomplete): merge snippets at the command position

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

* test(autocomplete): place snippet tests where the test runner finds them

The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.

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

* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path

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

* feat(autocomplete): wire snippets into the terminal + preview snippet command

* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history

The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.

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

* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers

In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.

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

* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun

Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:03:33 +08:00
陈大猫
67448cea65 feat(terminal): configurable startup-command delay + multi-line sequencing (#1096)
* feat(terminal): add global startupCommandDelayMs setting

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

* feat(terminal): add startup-command line-split and delay-clamp helpers

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

* feat(terminal): run multi-line startup commands in sequence with configurable delay

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

* feat(terminal): expose startup command delay in Terminal settings

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

* refactor(terminal): keep startup-command line content verbatim

splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.

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

* fix(sync): include startupCommandDelayMs in synced terminal settings

Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:47:00 +08:00
陈大猫
770b06a9ee fix(ai): make Claude Code agent diagnosable + configurable (auth) (#1095)
* feat(ai): add Claude auth-presence detection helper

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

* fix(ai): surface actionable Claude auth errors and reap stuck agent processes

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

* feat(ai): add pure helpers for Claude config dir + env editor

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

* feat(ai): let users set Claude config dir and env vars in settings

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

* polish(ai): harden Claude config env, de-dupe error text, label a11y

- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
  if a user types them into the free-text env editor, so they can't clobber
  the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
  so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
  via htmlFor/id.

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

* feat(ai): make the Claude auth & config section collapsible

The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.

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

* fix(ai): preserve raw env editor text; don't encourage plaintext secrets

P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).

P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).

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

* fix(ai): expand ~ in Claude config directory before passing to the agent

CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.

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

* fix(ai): recreate cached Claude provider when its env config changes

Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:59:48 +08:00
陈大猫
1d50b2c4a1 feat(terminal): choose dark/light terminal theme when following app theme (#1094)
* feat(terminal): add per-mode follow-theme resolver and storage keys

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

* feat(terminal): persist per-mode follow-theme selections in settings state

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

* feat(sync): include per-mode follow terminal themes in cloud sync

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

* i18n(terminal): add per-mode follow-theme picker strings

* feat(terminal): add type filter and auto option to theme picker

* feat(terminal): pick dark/light terminal theme when following app theme

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

* fix(terminal): don't flag the auto sentinel as a missing theme in the picker

The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.

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

* i18n(terminal): align zh-CN dark/light wording with app convention

Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.

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

* fix(terminal): match follow-theme preview fallback to runtime resolution

The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:20:45 +08:00
陈大猫
453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090)
Some checks failed
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 / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
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 / bump homebrew tap (push) Has been cancelled
2026-05-25 15:19:27 +08:00
陈大猫
a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫
e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫
38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

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

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫
fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫
e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

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

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
52 changed files with 2175 additions and 297 deletions

15
App.tsx
View File

@@ -1197,12 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const toggleSidePanelRef = useRef<(() => void) | null>(null);
// Populated below so the hotkey dispatcher can open the Settings window
// even though `handleOpenSettings` is declared further down in the file.
const handleOpenSettingsRef = useRef<() => void>(() => {});
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
@@ -1382,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
@@ -1402,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
@@ -1481,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
@@ -2147,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -264,6 +264,10 @@ const en: Messages = {
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -362,6 +366,9 @@ const en: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
@@ -1945,6 +1952,13 @@ const en: Messages = {
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI

View File

@@ -264,6 +264,10 @@ const ru: Messages = {
'settings.terminal.theme.selectButton': 'Выбрать тему',
'settings.terminal.theme.followApp': 'Следовать теме приложения',
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
'settings.terminal.section.font': 'Шрифт',
'settings.terminal.section.cursor': 'Курсор',
'settings.terminal.section.keyboard': 'Клавиатура',
@@ -362,6 +366,9 @@ const ru: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
'settings.terminal.scrollback.rows': 'Количество строк *',
'settings.terminal.section.startupCommand': 'Команда запуска',
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
@@ -472,6 +479,7 @@ const ru: Messages = {
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
@@ -1977,6 +1985,13 @@ const ru: Messages = {
'ai.claude.path': 'Путь:',
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
'ai.claude.configDir': 'Каталог конфигурации',
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.envVars': 'Переменные окружения',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check': 'Проверить',
// AI GitHub Copilot CLI

View File

@@ -1409,6 +1409,10 @@ const zhCN: Messages = {
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1499,6 +1503,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.section.startupCommand': '启动命令',
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
@@ -1599,6 +1606,7 @@ const zhCN: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -1953,6 +1961,13 @@ const zhCN: Messages = {
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '认证与配置(可选)',
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude留空用默认',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE传给 Claude agent。明文存在本地——API key凭据建议用上面的「配置目录」claude 登录),不要放这里。',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI

View File

@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,6 +1,5 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -49,7 +51,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -254,6 +256,12 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
@@ -536,6 +544,10 @@ export const useSettingsState = () => {
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
@@ -669,6 +681,12 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
setTerminalThemeDarkId(value);
}
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
setTerminalThemeLightId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
@@ -862,6 +880,15 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync per-mode follow terminal themes from other windows
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
const next = e.newValue;
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
}
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
const next = e.newValue;
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
@@ -1011,6 +1038,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -1293,25 +1332,32 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
// When "Follow Application Theme" is enabled, honor the per-mode override
// (or auto-match the active UI theme preset when set to auto).
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const followedId = resolveFollowedTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
// Explicit override pointing at a deleted theme: fall through to the
// manual theme below.
}
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1348,6 +1394,10 @@ export const useSettingsState = () => {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,

View File

@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
const bridge = netcattyBridge.get();
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,

View File

@@ -43,6 +43,8 @@ import {
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -161,6 +163,7 @@ interface SyncPayloadImporters {
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
@@ -186,6 +189,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -309,6 +314,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
@@ -432,6 +441,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));

View File

@@ -65,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}

View File

@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
@@ -52,6 +51,7 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
@@ -70,7 +70,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
@@ -299,7 +299,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const terminalLogDataRef = useRef("");
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
@@ -320,21 +320,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const captureTerminalLogData = useCallback((data: string) => {
const replaySafeData = terminalLogSanitizerRef.current.append(data);
if (!replaySafeData) return;
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
connectionLogBufferRef.current.append(replaySafeData);
}, []);
const finalizeTerminalLogData = useCallback(() => {
const replaySafeData = terminalLogSanitizerRef.current.finish();
if (replaySafeData) {
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
connectionLogBufferRef.current.append(replaySafeData);
}
return terminalLogDataRef.current;
return connectionLogBufferRef.current.toString();
}, []);
const writeLocalTerminalData = useCallback((data: string) => {
@@ -389,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
// xterm runtime (and a few effects here) can drive the hook without making
// Terminal re-render on every suggestion update.
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -541,31 +538,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
});
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
// its state updates don't re-render this component (see render below).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux");
const autocompleteSettings = terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined;
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
const cwd = await resolvePreferredTerminalCwd({
@@ -640,9 +625,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
autocompleteCloseRef.current?.();
}
}, [isVisible, autocompleteClosePopup]);
}, [isVisible]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
@@ -940,7 +925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
terminalLogDataRef.current = "";
connectionLogBufferRef.current.reset();
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
@@ -1598,10 +1583,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (!noAutoRun) data = `${data}\r`;
// Broadcast the exact bytes the active session receives so peers mirror it,
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
// line-by-line on peers, since handleBroadcastInput writes bytes directly
// without re-wrapping. Without broadcasting at all, accepting a snippet in
// broadcast mode would clear peer input (the clear keystrokes already go
// through the broadcast-aware path) but never send the command.
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId);
}
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
@@ -2384,29 +2380,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
}
{/* Autocomplete — owns the hook + popup in its own component so
suggestion/selection updates don't re-render Terminal. Mounted
unconditionally; it gates the popup on `visible` internally. */}
<TerminalAutocomplete
termRef={termRef}
sessionId={sessionId}
hostId={host.id}
hostOs={autocompleteHostOs}
settings={autocompleteSettings}
protocol={host.protocol}
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
snippets={snippets}
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
visible={isVisible}
themeColors={effectiveTheme.colors}
containerRef={containerRef}
searchBarOffset={isSearchOpen ? 64 : 30}
keyEventRef={autocompleteKeyEventRef}
inputRef={autocompleteInputRef}
repositionRef={autocompleteRepositionRef}
closeRef={autocompleteCloseRef}
/>
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (

View File

@@ -57,6 +57,7 @@ import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
import { getTerminalPaneSnapshot, parseTerminalPaneSnapshot } from './terminalPaneVisibility';
import { getScopedTopTabsThemeId } from './terminalTopTabsTheme';
@@ -465,9 +466,8 @@ interface TerminalLayerProps {
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
}
interface TerminalPaneProps {
@@ -890,9 +890,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
toggleScriptsSidePanelRef,
activeSidePanelTabRef,
toggleSidePanelRef,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -1101,13 +1100,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
// Remember the last sub-panel shown per tab so the toggle shortcut can
// restore it after a close. Overwritten on open, never cleared on close.
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
useEffect(() => {
sidePanelOpenTabs.forEach((tab, tabId) => {
lastSidePanelTabRef.current.set(tabId, tab);
});
}, [sidePanelOpenTabs]);
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
if (activeSidePanelTabRef) {
activeSidePanelTabRef.current = activeSidePanelTab;
}
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
@@ -1845,13 +1849,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
useEffect(() => {
if (!closeSidePanelRef) return;
closeSidePanelRef.current = handleCloseSidePanel;
return () => {
closeSidePanelRef.current = null;
};
}, [closeSidePanelRef, handleCloseSidePanel]);
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
// host of the workspace's focused session or the active session. null = none.
const resolveSftpHostForTab = useCallback((tabId: string): Host | null => {
const stored = sftpHostForTabRef.current.get(tabId);
if (stored) return stored;
const currentWorkspace = activeWorkspaceRef.current;
const currentFocusedSessionId = focusedSessionIdRef.current;
const currentActiveSession = activeSessionRef.current;
const currentSessionHosts = sessionHostsMapRef.current;
if (currentWorkspace && currentFocusedSessionId) {
return currentSessionHosts.get(currentFocusedSessionId) ?? null;
}
if (currentActiveSession) {
return currentSessionHosts.get(currentActiveSession.id) ?? null;
}
return null;
}, []);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
@@ -1864,16 +1878,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
let host: Host | null = null;
const currentWorkspace = activeWorkspaceRef.current;
const currentFocusedSessionId = focusedSessionIdRef.current;
const currentActiveSession = activeSessionRef.current;
const currentSessionHosts = sessionHostsMapRef.current;
if (currentWorkspace && currentFocusedSessionId) {
host = currentSessionHosts.get(currentFocusedSessionId) ?? null;
} else if (currentActiveSession) {
host = currentSessionHosts.get(currentActiveSession.id) ?? null;
}
const host = resolveSftpHostForTab(tabId);
if (!host) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
@@ -1891,7 +1896,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, tab);
return next;
});
}, []);
}, [resolveSftpHostForTab]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
@@ -1931,6 +1936,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
// available) or scripts.
const handleToggleSidePanel = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const isOpen = sidePanelOpenTabsRef.current.has(tabId);
const sftpAvailable = !!resolveSftpHostForTab(tabId);
const fallbackTab: SidePanelTab = sftpAvailable ? 'sftp' : 'scripts';
const lastTab = lastSidePanelTabRef.current.get(tabId) ?? null;
const intent = resolveSidePanelToggleIntent<SidePanelTab>({ isOpen, lastTab, fallbackTab });
if (intent.kind === 'close') {
handleCloseSidePanel();
return;
}
// If the remembered panel is SFTP but no host is resolvable, use scripts.
const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab;
handleSwitchSidePanelTab(target);
}, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab]);
useEffect(() => {
if (!toggleSidePanelRef) return;
toggleSidePanelRef.current = handleToggleSidePanel;
return () => {
toggleSidePanelRef.current = null;
};
}, [toggleSidePanelRef, handleToggleSidePanel]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
handleSwitchSidePanelTab('theme');

View File

@@ -2,9 +2,10 @@
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { Check, Wand2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
/** Restrict the list to a single type; omit to show both sections. */
filterType?: 'dark' | 'light';
/** Render an "Auto (match app theme)" entry at the top. */
showAutoOption?: boolean;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const deletedSelectedTheme = useMemo(
() => (selectedThemeId
&& selectedThemeId !== TERMINAL_THEME_AUTO
&& !isUiMatchTerminalThemeId(selectedThemeId)
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
&& !customThemes.some((theme) => theme.id === selectedThemeId)
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
return { darkThemes: dark, lightThemes: light };
}, []);
const visibleCustomThemes = filterType
? customThemes.filter(theme => theme.type === filterType)
: customThemes;
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
return (
<>
{showAutoOption && (
<button
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
)}
>
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
<Wand2 size={14} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
{t('settings.terminal.theme.auto')}
</div>
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
</div>
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
</button>
)}
{hiddenSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
</div>
)}
{/* Dark Themes Section */}
{(!filterType || filterType === 'dark') && (
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Light Themes Section */}
{(!filterType || filterType === 'light') && (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Custom Themes Section */}
{customThemes.length > 0 && (
{visibleCustomThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
{visibleCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
splitClaudeEnv,
buildClaudeEnv,
parseEnvLines,
serializeEnvLines,
} from "../settings/tabs/ai/claudeConfigEnv";
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
const result = splitClaudeEnv({
CLAUDE_CONFIG_DIR: "/cfg",
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(result.configDir, "/cfg");
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
});
test("splitClaudeEnv handles undefined env", () => {
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
});
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
assert.deepEqual(
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
);
});
test("serializeEnvLines is the inverse for simple entries", () => {
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
});
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
});
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
const next = buildClaudeEnv(
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
"/cfg",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
);
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});

View File

@@ -15,6 +15,8 @@ interface ThemeSelectModalProps {
onClose: () => void;
selectedThemeId: string;
onSelect: (themeId: string) => void;
filterType?: 'dark' | 'light';
showAutoOption?: boolean;
}
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
@@ -22,6 +24,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
onClose,
selectedThemeId,
onSelect,
filterType,
showAutoOption,
}) => {
const { t } = useI18n();
@@ -85,6 +89,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
filterType={filterType}
showAutoOption={showAutoOption}
/>
</div>

View File

@@ -48,6 +48,7 @@ import {
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
// ---------------------------------------------------------------------------
// Props
@@ -125,6 +126,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const claudeManagedEnv = useMemo(
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
[externalAgents],
);
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
() => splitClaudeEnv(claudeManagedEnv),
[claudeManagedEnv],
);
const updateClaudeEnv = useCallback(
(nextConfigDir: string, nextEnvText: string) => {
setExternalAgents((prev) =>
prev.map((a) =>
a.id === "discovered_claude"
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
: a,
),
);
},
[setExternalAgents],
);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
@@ -542,6 +566,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
configDir={claudeConfigDir}
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
envText={claudeEnvText}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
/>
</div>

View File

@@ -27,6 +27,7 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../domain/terminalAppearance";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
@@ -315,6 +316,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
setFollowAppTerminalTheme: (value: boolean) => void;
terminalThemeDarkId: string;
setTerminalThemeDarkId: (id: string) => void;
terminalThemeLightId: string;
setTerminalThemeLightId: (id: string) => void;
lightUiThemeId: string;
darkUiThemeId: string;
terminalFontFamilyId: string;
setTerminalFontFamilyId: (id: string) => void;
terminalFontSize: number;
@@ -333,6 +340,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -364,6 +377,7 @@ export default function SettingsTerminalTab(props: {
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
@@ -375,6 +389,38 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
// Preview themes for the follow-app per-mode pickers. resolvedTheme is
// forced per slot so each preview reflects exactly that mode's selection.
const darkPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'dark',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const lightPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'light',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
@@ -556,7 +602,34 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{!followAppTerminalTheme && (
{followAppTerminalTheme ? (
<div className="space-y-2">
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.darkTheme")}
</div>
<ThemePreviewButton
theme={darkPreviewTheme}
onClick={() => setThemeModalSlot('dark')}
buttonLabel={terminalThemeDarkId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.lightTheme")}
</div>
<ThemePreviewButton
theme={lightPreviewTheme}
onClick={() => setThemeModalSlot('light')}
buttonLabel={terminalThemeLightId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
</div>
) : (
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
@@ -570,6 +643,17 @@ export default function SettingsTerminalTab(props: {
selectedThemeId={terminalThemeId}
onSelect={setTerminalThemeId}
/>
<ThemeSelectModal
open={themeModalSlot !== null}
onClose={() => setThemeModalSlot(null)}
selectedThemeId={themeModalSlot === 'dark' ? terminalThemeDarkId : terminalThemeLightId}
onSelect={(id) => {
if (themeModalSlot === 'dark') setTerminalThemeDarkId(id);
else if (themeModalSlot === 'light') setTerminalThemeLightId(id);
}}
filterType={themeModalSlot === 'light' ? 'light' : 'dark'}
showAutoOption
/>
{/* Theme action buttons */}
<div className="flex items-center gap-2 -mt-1">
@@ -996,6 +1080,29 @@ export default function SettingsTerminalTab(props: {
</div>
</div>
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
<div className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground mb-3">
{t("settings.terminal.startupCommandDelay.desc")}
</p>
<div className="space-y-1">
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
<Input
type="number"
min={0}
max={10000}
value={terminalSettings.startupCommandDelayMs}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 10000) {
updateTerminalSetting("startupCommandDelayMs", val);
}
}}
className="w-full"
/>
</div>
</div>
<SectionHeader title={t("settings.terminal.section.keywordHighlight")} />
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">

View File

@@ -1,10 +1,11 @@
import React from "react";
import { RefreshCw } from "lucide-react";
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 { ProviderIconBadge } from "./ProviderIconBadge";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
pathInfo: AgentPathInfo | null;
@@ -12,15 +13,40 @@ export const ClaudeCodeCard: React.FC<{
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
configDir,
onConfigDirChange,
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(configDir.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.claude.detecting')
@@ -83,6 +109,53 @@ export const ClaudeCodeCard: React.FC<{
</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.claude.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="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
<input
id="claude-config-dir"
type="text"
value={configDir}
onChange={(e) => onConfigDirChange(e.target.value)}
placeholder={t('ai.claude.configDir.placeholder')}
className="w-full 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"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea
id="claude-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.claude.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.claude.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
/**
* Pure helpers for the Claude Code card's "config directory + environment
* variables" editor. The managed Claude agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
* is preserved across edits but never shown in the env editor.
*/
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitClaudeEnv(
env: Record<string, string> | undefined,
): { configDir: string; envText: string } {
if (!env) return { configDir: "", envText: "" };
const configDir = env[CONFIG_DIR_KEY] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { configDir, envText: serializeEnvLines(rest) };
}
export function buildClaudeEnv(
prevEnv: Record<string, string> | undefined,
configDir: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
// Preserve discovery-owned key if present.
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
const trimmedDir = String(configDir || "").trim();
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
// Drop managed keys if a user typed them into the free-text editor — the
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,118 @@
import ReactDOM from "react-dom";
import type { ComponentProps, RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
useTerminalAutocomplete,
AutocompletePopup,
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
type PopupProps = ComponentProps<typeof AutocompletePopup>;
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
type HandlerRef<T> = { current: T | undefined };
interface TerminalAutocompleteProps {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
protocol?: string;
getCwd?: () => string | undefined;
onAcceptText: (text: string) => void;
snippets?: Snippet[];
onAcceptSnippet?: (snippet: Snippet) => void;
/** Whether this terminal tab is the visible one. */
visible: boolean;
themeColors: PopupProps["themeColors"];
containerRef: PopupProps["containerRef"];
searchBarOffset: number;
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
inputRef: HandlerRef<(data: string) => void>;
repositionRef: HandlerRef<() => void>;
closeRef: HandlerRef<() => void>;
}
/**
* Owns the terminal autocomplete hook and renders its popup.
*
* Kept as its own component so the frequent autocomplete state updates
* (suggestions, selection, live-preview navigation) re-render only this small
* subtree rather than the whole Terminal component. The hook's handlers are
* surfaced back to Terminal through refs so the xterm runtime can call them.
*
* Must be mounted unconditionally for the terminal session's lifetime: the hook
* records command history on Enter and intercepts completion keys even while no
* popup is visible. Visibility only gates the rendered popup, not the hook.
*/
export function TerminalAutocomplete({
termRef,
sessionId,
hostId,
hostOs,
settings,
protocol,
getCwd,
onAcceptText,
snippets,
onAcceptSnippet,
visible,
themeColors,
containerRef,
searchBarOffset,
keyEventRef,
inputRef,
repositionRef,
closeRef,
}: TerminalAutocompleteProps) {
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId,
hostOs,
settings,
onAcceptText,
snippets,
onAcceptSnippet,
protocol,
getCwd,
});
// Surface the handlers for runtime wiring. They have stable identities
// (useCallback over refs), so assigning during render is cheap and mirrors
// the wiring Terminal did inline before this was extracted.
keyEventRef.current = autocomplete.handleKeyEvent;
inputRef.current = autocomplete.handleInput;
repositionRef.current = autocomplete.repositionPopup;
closeRef.current = autocomplete.closePopup;
const { state } = autocomplete;
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
return null;
}
// Portal to body so the popup escapes the terminal container's overflow.
return ReactDOM.createPortal(
<AutocompletePopup
suggestions={state.suggestions}
selectedIndex={state.selectedIndex}
position={state.popupPosition}
cursorLineTop={state.popupCursorLineTop}
cursorLineBottom={state.popupCursorLineBottom}
visible={state.popupVisible}
expandUpward={state.expandUpward}
themeColors={themeColors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={state.subDirPanels}
subDirFocusLevel={state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={searchBarOffset}
onDismiss={autocomplete.closePopup}
/>,
document.body,
);
}

View File

@@ -59,6 +59,7 @@ const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
};
/** Lucide icon components for file types in path suggestions */
@@ -353,8 +354,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
{suggestion.displayText}
</span>
{/* Inline description (truncated) */}
{suggestion.description && (
{/* Inline description (truncated). Snippets show only their label
in the row — the full command lives in the detail preview. */}
{suggestion.source !== "snippet" && suggestion.description && (
<span
style={{
fontSize: "11px",
@@ -481,7 +483,22 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.description}
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
</div>
)}

View File

@@ -30,9 +30,11 @@ import {
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import type { Snippet } from "../../../domain/models";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet";
export interface CompletionSuggestion {
/** The text to insert */
@@ -49,6 +51,8 @@ export interface CompletionSuggestion {
frequency?: number;
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
/** For snippet suggestions: the source snippet (used by the accept path). */
snippet?: Snippet;
}
export interface CompletionContext {
@@ -168,6 +172,8 @@ export async function getCompletions(
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15 } = options;
@@ -290,6 +296,16 @@ export async function getCompletions(
}
}
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
suggestions.push(snippetSuggestion);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);

View File

@@ -0,0 +1,49 @@
/**
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Each suggestion carries the full Snippet so the
* accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function appliesToHost(snippet: Snippet, hostId?: string): boolean {
if (!snippet.targets || snippet.targets.length === 0) return true;
return hostId !== undefined && snippet.targets.includes(hostId);
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!appliesToHost(snippet, options.hostId)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
if (!matches) continue;
out.push({
text: snippet.label,
displayText: snippet.label,
description: snippet.command,
source: "snippet",
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
snippet,
});
}
out.sort((a, b) => b.score - a.score);
return out;
}

View File

@@ -18,6 +18,7 @@ import {
type PromptDetectionResult,
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import type { Snippet } from "../../../domain/models";
import { recordCommand } from "./commandHistoryStore";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
@@ -47,6 +48,18 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
fastTypingThresholdMs: 40,
};
/**
* Whether completion work is worth doing — i.e. whether anything would
* actually be rendered. With both the popup and ghost text disabled, querying
* completions only to discard the result is pure main-thread waste, so callers
* skip it entirely.
*/
export function shouldQueryCompletions(
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
): boolean {
return settings.showPopupMenu || settings.showGhostText;
}
/** Shared empty state to avoid creating new objects on every reset */
const EMPTY_STATE: AutocompleteState = Object.freeze({
suggestions: [],
@@ -100,6 +113,10 @@ interface UseTerminalAutocompleteOptions {
protocol?: string;
/** Get current working directory (from OSC 7 or other source) */
getCwd?: () => string | undefined;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Accept a snippet — clears typed input then runs it (host-canonical send) */
onAcceptSnippet?: (snippet: Snippet) => void;
}
export interface TerminalAutocompleteHandle {
@@ -206,7 +223,7 @@ export function getCommandToRecordOnEnter(
export function useTerminalAutocomplete(
options: UseTerminalAutocompleteOptions,
): TerminalAutocompleteHandle {
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd } = options;
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd, snippets, onAcceptSnippet } = options;
const rawSettings: AutocompleteSettings = {
...DEFAULT_AUTOCOMPLETE_SETTINGS,
...userSettings,
@@ -228,6 +245,10 @@ export function useTerminalAutocomplete(
settingsRef.current = settings;
const onAcceptTextRef = useRef(onAcceptText);
onAcceptTextRef.current = onAcceptText;
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
const onAcceptSnippetRef = useRef(onAcceptSnippet);
onAcceptSnippetRef.current = onAcceptSnippet;
const hostIdRef = useRef(hostId);
hostIdRef.current = hostId;
const hostOsRef = useRef(hostOs);
@@ -640,6 +661,15 @@ export function useTerminalAutocomplete(
return;
}
// Nothing will be rendered when both the popup and ghost text are off, so
// don't run the (potentially expensive) completion query just to throw the
// result away. Clear any stale state and bail before touching history,
// fig specs, or remote path lookups.
if (!shouldQueryCompletions(settingsRef.current)) {
clearState();
return;
}
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
@@ -678,6 +708,7 @@ export function useTerminalAutocomplete(
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
cwd,
snippets: snippetsRef.current,
});
if (disposedRef.current || version !== fetchVersionRef.current) return;
@@ -695,7 +726,8 @@ export function useTerminalAutocomplete(
if (settingsRef.current.showGhostText) {
const ghost = ghostAddonRef.current;
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
// Snippets are popup-only — never used as inline ghost text.
const nextSuggestion = completions.find((c) => c.source !== "snippet")?.text ?? null;
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
if (ghostDecision.type === "show") {
ghost?.show(ghostDecision.suggestion, input);
@@ -1222,6 +1254,13 @@ export function useTerminalAutocomplete(
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (e.key === "Enter") {
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
if (selected?.source === "snippet" && selected.snippet) {
e.preventDefault();
previewActiveRef.current = false;
acceptSnippet(selected.snippet);
return false; // consume — run the snippet, not the typed text
}
clearState();
previewActiveRef.current = false;
return true;
@@ -1257,8 +1296,11 @@ export function useTerminalAutocomplete(
const term = termRef.current;
if (!term) return;
const baseline = previewBaselineRef.current;
const selected = index >= 0 ? s.suggestions[index] : null;
// Snippets aren't literal completions — keep the user's typed text in the
// line (the popup detail panel shows the full command instead).
const candidate =
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
selected && selected.source !== "snippet" ? selected.text : baseline;
const { prompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
@@ -1278,6 +1320,26 @@ export function useTerminalAutocomplete(
lastAcceptedCommandRef.current = isPreview ? candidate : null;
}, [termRef, writeToTerminal]);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
const acceptSnippet = useCallback((snippet: Snippet) => {
const term = termRef.current;
if (term) {
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (prompt.isAtPrompt && prompt.userInput.length > 0) {
const clearSequence = hostOsRef.current === "windows"
? "\b".repeat(prompt.userInput.length)
: "\x15"; // Ctrl+U (readline kill-line)
writeToTerminal(clearSequence);
}
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
onAcceptSnippetRef.current?.(snippet);
clearState();
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
}, [termRef, writeToTerminal]);
/**
* Insert a suggestion into the terminal.
* @param execute If true, also sends \r to execute the command.
@@ -1350,9 +1412,13 @@ export function useTerminalAutocomplete(
*/
const selectSuggestion = useCallback(
(suggestion: CompletionSuggestion) => {
if (suggestion.source === "snippet" && suggestion.snippet) {
acceptSnippet(suggestion.snippet);
return;
}
insertSuggestion(suggestion, false);
},
[insertSuggestion],
[insertSuggestion, acceptSnippet],
);
const closePopup = useCallback(() => {

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getCompletions } from "./autocomplete/completionEngine";
import type { Snippet } from "../../domain/models";
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
test("getCompletions includes snippet suggestions at the command position", async () => {
const out = await getCompletions("dep", { snippets: [deploySnippet] });
const snip = out.find((s) => s.source === "snippet");
assert.ok(snip, "expected a snippet suggestion");
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions does not surface snippets past the command position", async () => {
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
test("queries completions when the popup menu is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
true,
);
});
test("queries completions when ghost text is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
true,
);
});
test("skips completion work when both popup and ghost text are off", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
false,
);
});

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
test("concatenates appended chunks while under the cap", () => {
const buf = createConnectionLogBuffer(100);
buf.append("foo");
buf.append("bar");
buf.append("baz");
assert.equal(buf.toString(), "foobarbaz");
});
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
const max = 10;
const buf = createConnectionLogBuffer(max);
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-max));
assert.equal(buf.toString().length, max);
});
test("trims a single chunk larger than the cap to its last maxChars", () => {
const buf = createConnectionLogBuffer(5);
buf.append("0123456789");
assert.equal(buf.toString(), "56789");
});
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
const buf = createConnectionLogBuffer(6);
buf.append("abcde"); // 5
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
assert.equal(buf.toString(), "efghij");
});
test("stays correct across many small appends (ring semantics)", () => {
const max = 50;
const buf = createConnectionLogBuffer(max);
let naive = "";
for (let i = 0; i < 500; i++) {
const chunk = `x${i}-`;
buf.append(chunk);
naive += chunk;
}
assert.equal(buf.toString(), naive.slice(-max));
});
test("reset clears the buffer", () => {
const buf = createConnectionLogBuffer(100);
buf.append("hello");
buf.reset();
assert.equal(buf.toString(), "");
buf.append("world");
assert.equal(buf.toString(), "world");
});
test("ignores empty appends", () => {
const buf = createConnectionLogBuffer(100);
buf.append("a");
buf.append("");
buf.append("b");
assert.equal(buf.toString(), "ab");
});
test("keeps the segment count bounded across many tiny appends", () => {
// The whole point of the rewrite: trimming must not walk one array entry
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
// single-char appends arrive once it's at capacity.
const maxChars = 100;
const blockSize = 10;
const buf = createConnectionLogBuffer(maxChars, blockSize);
let naive = "";
for (let i = 0; i < 10000; i++) {
buf.append("x");
naive += "x";
}
assert.ok(
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
`segmentCount ${buf.segmentCount()} exceeded the bound`,
);
assert.equal(buf.toString(), naive.slice(-maxChars));
});
test("seals and trims whole blocks with a small blockSize", () => {
const buf = createConnectionLogBuffer(10, 4);
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
});

View File

@@ -0,0 +1,94 @@
/**
* A bounded, append-only text buffer that retains only the last `maxChars`
* characters — the connection log used for diagnostics/replay.
*
* The naive implementation (`log += chunk; if (log.length > max) log =
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
* cap is reached — on the render thread, for every output chunk including each
* echoed keystroke.
*
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
* the oldest data therefore only ever drops/slices a handful of blocks — never
* one array element per append, which would make trim O(number of appends) and
* defeat the purpose. Append is amortized O(chunk); the full string is
* materialized only on `toString()` (called rarely, on finalize).
*/
export interface ConnectionLogBuffer {
append(chunk: string): void;
toString(): string;
reset(): void;
/**
* Number of internal string segments currently retained. Exposed for tests
* to assert the bounded-memory / bounded-trim property.
*/
segmentCount(): number;
}
const DEFAULT_BLOCK_SIZE = 64 * 1024;
export function createConnectionLogBuffer(
maxChars: number,
blockSize: number = DEFAULT_BLOCK_SIZE,
): ConnectionLogBuffer {
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
let tail = ""; // open block currently being filled (newest data)
let total = 0; // total retained length across blocks + tail
const trim = () => {
let overflow = total - maxChars;
if (overflow <= 0) return;
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
while (overflow > 0 && blocks.length > 0) {
const head = blocks[0];
if (head.length <= overflow) {
blocks.shift();
total -= head.length;
overflow -= head.length;
} else {
blocks[0] = head.slice(overflow);
total -= overflow;
overflow = 0;
}
}
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
// maxChars); keep its last `maxChars` characters.
if (overflow > 0) {
tail = tail.slice(overflow);
total -= overflow;
}
};
return {
append(chunk: string): void {
if (!chunk) return;
// A single chunk at/over the cap can only contribute its own tail.
if (chunk.length >= maxChars) {
blocks = [];
tail = chunk.slice(chunk.length - maxChars);
total = tail.length;
return;
}
tail += chunk;
total += chunk.length;
if (tail.length >= blockSize) {
blocks.push(tail);
tail = "";
}
if (total > maxChars) trim();
},
toString(): string {
return blocks.length > 0 ? blocks.join("") + tail : tail;
},
reset(): void {
blocks = [];
tail = "";
total = 0;
},
segmentCount(): number {
return blocks.length + (tail.length > 0 ? 1 : 0);
},
};
}

View File

@@ -1,7 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
import {
createTerminalSessionStarters,
getMissingChainHostIds,
splitStartupCommandLines,
normalizeStartupCommandDelay,
} from "./createTerminalSessionStarters";
import { createPromptLineBreakState } from "./promptLineBreak";
import { pasteTextIntoTerminal } from "./terminalUserPaste";
@@ -2279,6 +2284,110 @@ test("startTelnet waits for auto-login before running the startup command", asyn
assert.equal(disposedAutoLoginCancelListener, true);
});
test("startTelnet runs a multi-line startup command in sequence", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let capturedOptions: Record<string, unknown> | null = null;
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let disposedAutoLoginCancelListener = false;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onTelnetAutoLoginComplete: (sessionId: string, cb: (evt: { sessionId: string }) => void) => {
assert.equal(sessionId, "session-1");
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => () => {
disposedAutoLoginCancelListener = true;
},
onChainProgress: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "ssh-user",
telnetUsername: "telnet-user",
telnetPassword: "",
telnetPort: 2323,
startupCommand: "first cmd\nsecond cmd",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: { startupCommandDelayMs: 20 },
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
onCommandExecuted: (command: string) => {
executedCommands.push(command);
},
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.ok(capturedOptions);
assert.ok(autoLoginComplete);
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(writtenCommands, []);
assert.deepEqual(executedCommands, []);
autoLoginComplete({ sessionId: "session-1" });
// Wait long enough for both lines (delay before first + delay between).
await new Promise((resolve) => setTimeout(resolve, 200));
assert.deepEqual(writtenCommands, ["first cmd\r", "second cmd\r"]);
assert.deepEqual(executedCommands, ["first cmd", "second cmd"]);
assert.equal(disposedAutoLoginCancelListener, true);
});
test("startTelnet cancels pending startup command when user takes over", async () => {
const writtenCommands: string[] = [];
let capturedOptions: Record<string, unknown> | null = null;
@@ -2623,3 +2732,25 @@ test("startTelnet rejects configured proxies instead of connecting directly", as
assert.equal(started, false);
assert.match(error, /Telnet does not support proxy/);
});
test("splitStartupCommandLines drops blank lines but keeps content verbatim", () => {
assert.deepEqual(splitStartupCommandLines("sudo -i\nalias dc=\"docker compose\""), [
"sudo -i",
'alias dc="docker compose"',
]);
// Single-line content is preserved verbatim (leading/trailing spaces kept).
assert.deepEqual(splitStartupCommandLines(" cd /app "), [" cd /app "]);
assert.deepEqual(splitStartupCommandLines("a\n\n \nb"), ["a", "b"]);
assert.deepEqual(splitStartupCommandLines("echo hi\r\nwhoami"), ["echo hi", "whoami"]);
assert.deepEqual(splitStartupCommandLines(""), []);
assert.deepEqual(splitStartupCommandLines(" "), []);
});
test("normalizeStartupCommandDelay defaults and clamps", () => {
assert.equal(normalizeStartupCommandDelay(undefined), 600);
assert.equal(normalizeStartupCommandDelay(Number.NaN), 600);
assert.equal(normalizeStartupCommandDelay(0), 0);
assert.equal(normalizeStartupCommandDelay(1500), 1500);
assert.equal(normalizeStartupCommandDelay(-50), 0);
assert.equal(normalizeStartupCommandDelay(999999), 10000);
});

View File

@@ -27,6 +27,7 @@ import {
syncPromptLineBreakState,
type PromptLineBreakState,
} from "./promptLineBreak";
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
};
export type PendingAuth = {
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
}
};
// Output back-pressure. Without this the renderer can't slow a flooding source,
// so a busy stream grows the write queue and xterm's buffer unbounded. The
// controller tracks bytes received-but-not-yet-rendered and asks the main
// process to pause/resume the session's source stream at these watermarks.
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
const getFlowController = (
ctx: TerminalSessionStartersContext,
term: XTerm,
): OutputFlowController => {
let controller = terminalFlowControllers.get(term);
if (!controller) {
controller = createOutputFlowController({
highWaterMark: FLOW_HIGH_WATER_MARK,
lowWaterMark: FLOW_LOW_WATER_MARK,
onPause: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
},
onResume: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
},
});
terminalFlowControllers.set(term, controller);
}
return controller;
};
const writeTerminalLine = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -268,6 +303,8 @@ const writeSessionData = (
term: XTerm,
data: string,
) => {
const flow = getFlowController(ctx, term);
flow.received(data.length);
enqueueTerminalWrite(term, (done) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
@@ -301,6 +338,8 @@ const writeSessionData = (
handleTerminalOutputAutoScroll(ctx, term);
}
done();
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
flow.written(data.length);
};
term.write(displayData, afterWrite);
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
},
) => {
ctx.sessionRef.current = id;
// Clear any stale back-pressure accounting from a prior session on this term.
getFlowController(ctx, term).reset();
ctx.onSessionAttached?.(id);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
@@ -375,6 +416,29 @@ const attachSessionToTerminal = (
});
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
/**
* Split a (possibly multi-line) startup command into non-empty lines, dropping
* blank/whitespace-only lines but preserving each line's content verbatim — so
* a single-line command stays byte-identical to what the user typed (e.g. a
* leading space for `HISTCONTROL=ignorespace` is kept). Trailing `\r` from
* CRLF input is normalized away.
*/
export function splitStartupCommandLines(commandText: string): string[] {
return String(commandText || "")
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.trim().length > 0);
}
/** Clamp a configured startup-command delay; fall back to the default when unset/invalid. */
export function normalizeStartupCommandDelay(raw: number | undefined): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : STARTUP_COMMAND_DEFAULT_DELAY_MS;
return Math.max(0, Math.min(STARTUP_COMMAND_MAX_DELAY_MS, value));
}
const scheduleStartupCommand = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -386,24 +450,65 @@ const scheduleStartupCommand = (
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
const timeoutId = setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const delayMs = normalizeStartupCommandDelay(settings?.startupCommandDelayMs);
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const sessionIsCurrent = () =>
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
// Enter and no line-splitting — unchanged behavior.
if (ctx.noAutoRun) {
timeoutId = setTimeout(() => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
onSettled?.();
}, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
// Auto-run: send each non-empty line in sequence, waiting delayMs before the
// first and between each, so a line runs inside any sub-shell opened by a
// previous line (e.g. `sudo -i` then `alias ...`).
const lines = splitStartupCommandLines(commandToRun);
if (lines.length === 0) {
onSettled?.();
return undefined;
}
let index = 0;
const runNext = () => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
const suffix = ctx.noAutoRun ? "" : "\r";
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
automated: true,
});
if (!ctx.noAutoRun) {
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, commandToRun);
const line = lines[index];
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
index += 1;
if (index < lines.length) {
timeoutId = setTimeout(runNext, delayMs);
} else {
onSettled?.();
}
onSettled?.();
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
return () => clearTimeout(timeoutId);
};
timeoutId = setTimeout(runNext, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
};
const runDistroDetection = async (
@@ -1204,6 +1309,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
ctx.sessionRef.current = id;
getFlowController(ctx, term).reset();
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {

View File

@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createOutputFlowController } from "./outputFlowController.ts";
function make(high = 100, low = 30) {
const events: string[] = [];
const controller = createOutputFlowController({
highWaterMark: high,
lowWaterMark: low,
onPause: () => events.push("pause"),
onResume: () => events.push("resume"),
});
return { controller, events };
}
test("does not pause while below the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(50);
controller.received(49); // 99 < 100
assert.deepEqual(events, []);
assert.equal(controller.isPaused(), false);
});
test("pauses once when crossing the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(60);
controller.received(60); // 120 >= 100 -> pause
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
// Further received while already paused must not re-fire pause.
controller.received(100);
assert.deepEqual(events, ["pause"]);
});
test("resumes once when draining to at/below the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(50); // 70 still > 30, no resume
assert.deepEqual(events, ["pause"]);
controller.written(50); // 20 <= 30 -> resume
assert.deepEqual(events, ["pause", "resume"]);
assert.equal(controller.isPaused(), false);
});
test("does not resume when still above the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(80); // 40 > 30
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
});
test("never lets pending go negative", () => {
const { controller } = make(100, 30);
controller.received(10);
controller.written(50); // over-written
assert.equal(controller.pendingBytes(), 0);
});
test("supports repeated pause/resume cycles", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(120); // resume (0 <= 30)
controller.received(120); // pause again
controller.written(120); // resume again
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
});
test("reset clears state without firing callbacks", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.reset();
assert.equal(controller.isPaused(), false);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, ["pause"]); // reset itself is silent
// A fresh cycle works after reset.
controller.received(120);
assert.deepEqual(events, ["pause", "pause"]);
});
test("ignores non-positive amounts", () => {
const { controller, events } = make(100, 30);
controller.received(0);
controller.written(0);
controller.received(-5);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,73 @@
/**
* Watermark-based flow control for terminal output.
*
* SSH/PTY output has no back-pressure by default: the source streams as fast as
* it can, the main process forwards it over IPC, and the renderer queues every
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
* buffer grow without bound — memory climbs and the whole UI, typing included,
* janks.
*
* This tracks bytes that have been received but not yet acknowledged by xterm's
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
* backlog hovers near zero, so this never engages.
*/
export interface OutputFlowController {
/** Account bytes handed to xterm (call when a chunk is received). */
received(bytes: number): void;
/** Account bytes whose xterm write callback has fired. */
written(bytes: number): void;
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
reset(): void;
pendingBytes(): number;
isPaused(): boolean;
}
export interface OutputFlowControllerOptions {
highWaterMark: number;
lowWaterMark: number;
/** Asked to pause the source when the backlog crosses the high watermark. */
onPause: () => void;
/** Asked to resume the source when the backlog drains to the low watermark. */
onResume: () => void;
}
export function createOutputFlowController(
options: OutputFlowControllerOptions,
): OutputFlowController {
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
let pending = 0;
let paused = false;
return {
received(bytes: number): void {
if (bytes <= 0) return;
pending += bytes;
if (!paused && pending >= highWaterMark) {
paused = true;
onPause();
}
},
written(bytes: number): void {
if (bytes <= 0) return;
pending -= bytes;
if (pending < 0) pending = 0;
if (paused && pending <= lowWaterMark) {
paused = false;
onResume();
}
},
reset(): void {
pending = 0;
paused = false;
},
pendingBytes(): number {
return pending;
},
isPaused(): boolean {
return paused;
},
};
}

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter";
import type { Snippet } from "../../domain/models";
const snip = (over: Partial<Snippet>): Snippet => ({
id: over.id ?? "s1",
label: over.label ?? "deploy",
command: over.command ?? "echo deploy",
...over,
});
test("matches by label prefix and carries the snippet + command preview", () => {
const s = snip({ id: "a", label: "deploy", command: "kubectl apply -f .\nkubectl rollout status deploy" });
const out = getSnippetSuggestions("dep", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].source, "snippet");
assert.equal(out[0].displayText, "deploy");
assert.equal(out[0].description, "kubectl apply -f .\nkubectl rollout status deploy");
assert.equal(out[0].snippet?.id, "a");
});
test("matches by command first line", () => {
const s = snip({ id: "b", label: "k8s", command: "kubectl get pods" });
const out = getSnippetSuggestions("kubectl", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].snippet?.id, "b");
});
test("is case-insensitive and prefix outranks substring", () => {
const a = snip({ id: "p", label: "Backup", command: "tar czf b.tgz ." });
const b = snip({ id: "q", label: "db-backup", command: "pg_dump" });
const out = getSnippetSuggestions("backup", [a, b], {});
assert.deepEqual(out.map((o) => o.snippet?.id), ["p", "q"]);
});
test("filters by host targets when set", () => {
const scoped = snip({ id: "t", label: "restart", command: "systemctl restart x", targets: ["host-2"] });
const global = snip({ id: "g", label: "restart-all", command: "echo all" });
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-1" }).map((o) => o.snippet?.id), ["g"]);
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
});
test("no match returns empty; empty input returns empty", () => {
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
});

View File

@@ -37,5 +37,6 @@ export const terminalLayerAreEqual = (
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.onToggleBroadcast === next.onToggleBroadcast &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.toggleSidePanelRef === next.toggleSidePanelRef &&
prev.identities === next.identities
);

View File

@@ -435,6 +435,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
// SFTP Operations
@@ -476,6 +477,7 @@ export interface TerminalSettings {
scrollback: number; // Number of lines kept in buffer
drawBoldInBrightColors: boolean; // Draw bold text in bright colors
terminalEmulationType: TerminalEmulationType; // Terminal emulation type (TERM env var)
startupCommandDelayMs: number; // Delay (ms) after connect before sending the startup command; also used between multiple lines
// Font
fontLigatures: boolean; // Enable font ligatures
@@ -684,6 +686,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: 10000,
drawBoldInBrightColors: true,
terminalEmulationType: 'xterm-256color',
startupCommandDelayMs: 600,
fontLigatures: true,
fontWeight: 400,
fontWeightBold: 700,

View File

@@ -192,6 +192,8 @@ export interface SyncPayload {
// Terminal
terminalTheme?: string;
followAppTerminalTheme?: boolean;
terminalThemeDark?: string;
terminalThemeLight?: string;
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;

View File

@@ -4,6 +4,8 @@ import assert from "node:assert/strict";
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveFollowedTerminalThemeId,
TERMINAL_THEME_AUTO,
} from "./terminalAppearance";
import type { Host, TerminalTheme } from "./models";
@@ -149,3 +151,73 @@ test("terminal appearance reset clears only appearance fields", () => {
assert.equal(merged.fontSize, undefined);
assert.equal(merged.fontSizeOverride, false);
});
test("follow-theme resolver: dark + auto follows the active dark UI preset", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"ui-midnight",
);
});
test("follow-theme resolver: light + auto follows the active light UI preset", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "light",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"ui-snow",
);
});
test("follow-theme resolver: explicit dark override wins over auto-matching", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: "dracula",
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"dracula",
);
});
test("follow-theme resolver: explicit light override wins over auto-matching", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "light",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: "solarized-light",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
fallbackThemeId: "netcatty-dark",
}),
"solarized-light",
);
});
test("follow-theme resolver: auto with no UI match falls back to fallbackThemeId", () => {
assert.equal(
resolveFollowedTerminalThemeId({
resolvedTheme: "dark",
terminalThemeDarkId: TERMINAL_THEME_AUTO,
terminalThemeLightId: TERMINAL_THEME_AUTO,
lightUiThemeId: "no-such-ui-theme",
darkUiThemeId: "no-such-ui-theme",
fallbackThemeId: "netcatty-dark",
}),
"netcatty-dark",
);
});

View File

@@ -88,6 +88,39 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
/**
* Sentinel stored in the per-mode follow-theme settings meaning "let the
* terminal theme follow the active UI theme preset" (the legacy
* auto-matching behavior), as opposed to a concrete terminal theme id.
*/
export const TERMINAL_THEME_AUTO = 'auto';
/**
* Resolve which terminal theme id to use while "Follow Application Theme" is
* enabled, honoring the user's per-mode override.
*
* - A concrete theme id in the active mode's setting is used as-is.
* - `TERMINAL_THEME_AUTO` (the default) keeps the legacy behavior: match the
* active UI theme preset, then `fallbackThemeId` when no UI match exists.
*/
export const resolveFollowedTerminalThemeId = (args: {
resolvedTheme: 'light' | 'dark';
terminalThemeDarkId: string;
terminalThemeLightId: string;
lightUiThemeId: string;
darkUiThemeId: string;
fallbackThemeId: string;
}): string => {
const selected = args.resolvedTheme === 'dark'
? args.terminalThemeDarkId
: args.terminalThemeLightId;
if (selected && selected !== TERMINAL_THEME_AUTO) return selected;
const activeUiThemeId = args.resolvedTheme === 'dark'
? args.darkUiThemeId
: args.lightUiThemeId;
return getTerminalThemeForUiTheme(activeUiThemeId) ?? args.fallbackThemeId;
};
type ParsedHslToken = {
hue: number;
saturation: number;

View File

@@ -9,3 +9,12 @@ test("normalizeTerminalSettings disables prompt line breaks by default", () => {
assert.equal(settings.forcePromptNewLine, false);
});
test("normalizeTerminalSettings defaults startupCommandDelayMs to 600", () => {
assert.equal(normalizeTerminalSettings().startupCommandDelayMs, 600);
});
test("normalizeTerminalSettings preserves a provided startupCommandDelayMs", () => {
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 0 }).startupCommandDelayMs, 0);
assert.equal(normalizeTerminalSettings({ startupCommandDelayMs: 1500 }).startupCommandDelayMs, 1500);
});

View File

@@ -0,0 +1,50 @@
"use strict";
/**
* Claude Code auth/config detection helpers (main process).
*
* claude-agent-acp authenticates from env (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN)
* or from credentials stored under CLAUDE_CONFIG_DIR (default ~/.claude). We use
* this to turn opaque "-32603 Internal error" failures into an actionable message
* when no auth is configured. NOTE: macOS may store credentials in the Keychain
* rather than a file, so 'none' is a heuristic — callers must NOT hard-block on it;
* only use it to improve the error message after an actual failure.
*/
const { existsSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* Expand a leading "~" to the user's home directory. Env vars handed to a
* child process are NOT shell-expanded, so "~/.claude" would otherwise be
* treated as a literal directory named "~". Only a leading "~", "~/" or "~\"
* is expanded (not "~user"); other values pass through unchanged.
*/
function expandHomePath(p) {
if (typeof p !== "string") return p;
const trimmed = p.trim();
if (trimmed === "~") return os.homedir();
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return p;
}
function getClaudeConfigDir(env) {
const custom = typeof env?.CLAUDE_CONFIG_DIR === "string" ? env.CLAUDE_CONFIG_DIR.trim() : "";
return custom ? expandHomePath(custom) : path.join(os.homedir(), ".claude");
}
/**
* @returns {'env'|'credentials-file'|'none'}
*/
function detectClaudeAuthPresence(env, fileExists = existsSync) {
const apiKey = typeof env?.ANTHROPIC_API_KEY === "string" ? env.ANTHROPIC_API_KEY.trim() : "";
const authToken = typeof env?.ANTHROPIC_AUTH_TOKEN === "string" ? env.ANTHROPIC_AUTH_TOKEN.trim() : "";
if (apiKey || authToken) return "env";
if (fileExists(path.join(getClaudeConfigDir(env), ".credentials.json"))) return "credentials-file";
return "none";
}
module.exports = { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath };

View File

@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath } = require("./claudeAuth.cjs");
test("getClaudeConfigDir: defaults to ~/.claude", () => {
assert.equal(getClaudeConfigDir({}), path.join(os.homedir(), ".claude"));
});
test("getClaudeConfigDir: honors CLAUDE_CONFIG_DIR", () => {
assert.equal(getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "/custom/dir" }), "/custom/dir");
});
test("getClaudeConfigDir: expands a leading ~ in CLAUDE_CONFIG_DIR", () => {
assert.equal(
getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "~/.claude-work" }),
path.join(os.homedir(), ".claude-work"),
);
});
test("expandHomePath: expands '~' and '~/...', leaves others unchanged", () => {
assert.equal(expandHomePath("~"), os.homedir());
assert.equal(expandHomePath("~/x/y"), path.join(os.homedir(), "x/y"));
assert.equal(expandHomePath("/abs/path"), "/abs/path");
assert.equal(expandHomePath("~user/x"), "~user/x");
assert.equal(expandHomePath(""), "");
});
test("detectClaudeAuthPresence: ANTHROPIC_API_KEY in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: "sk-x" }, () => false), "env");
});
test("detectClaudeAuthPresence: ANTHROPIC_AUTH_TOKEN in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_AUTH_TOKEN: "tok" }, () => false), "env");
});
test("detectClaudeAuthPresence: blank env token is ignored", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: " " }, () => false), "none");
});
test("detectClaudeAuthPresence: credentials file under config dir => 'credentials-file'", () => {
const seen = [];
const result = detectClaudeAuthPresence(
{ CLAUDE_CONFIG_DIR: "/custom/dir" },
(p) => { seen.push(p); return p === path.join("/custom/dir", ".credentials.json"); },
);
assert.equal(result, "credentials-file");
assert.ok(seen.includes(path.join("/custom/dir", ".credentials.json")));
});
test("detectClaudeAuthPresence: nothing => 'none'", () => {
assert.equal(detectClaudeAuthPresence({}, () => false), "none");
});

View File

@@ -37,6 +37,11 @@ const {
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
const { detectClaudeAuthPresence, expandHomePath } = require("./ai/claudeAuth.cjs");
const CLAUDE_AUTH_HELP_MESSAGE =
"Claude Code has no usable authentication. Open Settings -> AI -> Claude Code and set a Config directory (point it at a folder where you've run `claude` login) or add an ANTHROPIC_API_KEY under Environment variables. Alternatively, run `claude` in a terminal to log in.";
const {
codexLoginSessions,
resolveCodexAcpBinaryPath,
@@ -559,6 +564,12 @@ function normalizeAgentEnv(env) {
if (!key || value == null) continue;
result[key] = String(value);
}
// CLAUDE_CONFIG_DIR is consumed as a filesystem path by the spawned agent,
// which won't shell-expand "~". Expand it here so "~/.claude" works and the
// stored value stays portable (each device expands to its own home).
if (result.CLAUDE_CONFIG_DIR) {
result.CLAUDE_CONFIG_DIR = expandHomePath(result.CLAUDE_CONFIG_DIR);
}
return result;
}
@@ -2451,6 +2462,9 @@ function registerHandlers(ipcMain) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let abortController = null;
// Hoisted so the catch block can reference them for Claude-specific error handling.
let isClaudeAgent = false;
let claudeAuthPresence = null;
try {
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
@@ -2503,8 +2517,14 @@ function registerHandlers(ipcMain) {
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
// For Claude: detect whether any auth is reachable so we can turn an
// opaque "-32603 Internal error" into actionable guidance on failure.
// Heuristic only (macOS may keep creds in Keychain) — never hard-block.
claudeAuthPresence = isClaudeAgent
? detectClaudeAuthPresence({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) })
: null;
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const effectiveToolIntegrationMode = normalizeToolIntegrationMode(toolIntegrationMode);
debugMcpLog("ACP request start", {
@@ -2568,7 +2588,13 @@ function registerHandlers(ipcMain) {
const authFingerprint = isCodexAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
: isClaudeAgent
// Fingerprint the Claude agent env (config dir + user env vars) so a
// settings change invalidates the cached per-session provider and the
// next turn respawns with the new config instead of reusing a stale
// process spawned with the old env.
? JSON.stringify(normalizeAgentEnv(requestedAgentEnv))
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -3009,11 +3035,18 @@ function registerHandlers(ipcMain) {
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
if (isClaudeAgent) {
// Reap the persistent agent process so a failed turn doesn't leak
// node.exe processes (provider uses persistSession:true).
cleanupAcpProvider(chatSessionId);
}
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: isCodexAgent
? "Codex returned an empty response. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key."
: "Agent returned an empty response.",
: isClaudeAgent && claudeAuthPresence === "none"
? CLAUDE_AUTH_HELP_MESSAGE
: "Agent returned an empty response.",
});
} else {
// Clear replay fallback when the recovered turn either streamed
@@ -3040,19 +3073,40 @@ function registerHandlers(ipcMain) {
} catch (err) {
console.error("[ACP] Handler caught error:", err?.message || err, err?.stack?.split("\n").slice(0, 3).join("\n"));
const normalized = extractCodexError(err);
const errMsg = normalized.message;
// #3 (light): include JSON-RPC code/data when present so Claude's bare
// "Internal error" isn't shown context-free.
const errCode = typeof err?.code === "number" ? err.code : err?.data?.code;
// Only surface data fields we don't already show (message/code) so the
// detail doesn't echo them back.
let errDetail = "";
if (err?.data && typeof err.data === "object") {
const extra = { ...err.data };
delete extra.code;
delete extra.message;
if (Object.keys(extra).length > 0) {
try { errDetail = JSON.stringify(extra); } catch { errDetail = ""; }
}
}
const errMsg = [normalized.message, errCode != null ? `(code ${errCode})` : "", errDetail]
.filter(Boolean)
.join(" ");
const isAuthErr = isCodexAuthError(normalized);
if (isAuthErr) {
console.error("[ACP] Auth error — user needs to re-login:", errMsg);
cleanupAcpProvider(chatSessionId);
} else if (isClaudeAgent) {
// #4: always reap the Claude provider/process tree on error.
cleanupAcpProvider(chatSessionId);
}
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: isAuthErr
? `Authentication failed. Connect Codex in Settings -> AI, or configure an enabled OpenAI provider API key.\n\nDetails: ${errMsg}`
: errMsg,
: isClaudeAgent && claudeAuthPresence === "none"
? `${CLAUDE_AUTH_HELP_MESSAGE}\n\nDetails: ${errMsg}`
: errMsg,
});
} finally {
acpActiveStreams.delete(requestId);

View File

@@ -0,0 +1,63 @@
"use strict";
/**
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
*
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
* adds that whole interval as latency to interactive echo — every keystroke
* round-trips through the buffer and waits out the timer before it can paint.
* Turn-based flushing coalesces only the data that has already arrived in the
* current turn, so a single echoed keystroke is forwarded almost immediately
* while bursts of output still collapse into one send.
*
* A byte cap still forces an immediate, synchronous flush so a flood of output
* can't grow the buffer without bound between turns.
*
* @param {(data: string) => void} sendFn delivers an accumulated batch
* @param {{ maxBufferSize?: number }} [options]
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
*/
function createPtyOutputBuffer(sendFn, options = {}) {
const maxBufferSize = options.maxBufferSize ?? 16384; // 16KB
let dataBuffer = "";
let scheduled = null;
const cancelScheduled = () => {
if (scheduled) {
clearImmediate(scheduled);
scheduled = null;
}
};
const flushNow = () => {
scheduled = null;
if (dataBuffer.length > 0) {
const pending = dataBuffer;
dataBuffer = "";
sendFn(pending);
}
};
const bufferData = (data) => {
dataBuffer += data;
if (dataBuffer.length >= maxBufferSize) {
// Large enough to ship right now — don't wait for the turn flush.
cancelScheduled();
flushNow();
} else if (!scheduled) {
scheduled = setImmediate(flushNow);
}
};
const flush = () => {
cancelScheduled();
flushNow();
};
return { bufferData, flush };
}
module.exports = { createPtyOutputBuffer };

View File

@@ -0,0 +1,90 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
/** Resolve after one event-loop turn (immediates have run). */
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("coalesces data buffered within the same turn into a single send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("a");
buffer.bufferData("b");
buffer.bufferData("c");
// Nothing is sent synchronously while still in the same turn.
assert.equal(sends.length, 0);
await tick();
assert.deepEqual(sends, ["abc"]);
});
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("x");
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
// immediate turn. Turn-based flushing must have delivered it by now.
await tick();
assert.deepEqual(sends, ["x"]);
});
test("flushes immediately and synchronously once the size cap is reached", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
});
buffer.bufferData("ab");
assert.equal(sends.length, 0); // under cap, still pending
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
// Cap flush happens synchronously, without waiting for the turn.
assert.deepEqual(sends, ["abcd"]);
// The pending turn flush must have been cancelled — no empty/duplicate send.
await tick();
assert.deepEqual(sends, ["abcd"]);
});
test("flush() forces a synchronous send and cancels the pending turn", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("hello");
buffer.flush();
assert.deepEqual(sends, ["hello"]);
await tick();
assert.deepEqual(sends, ["hello"]); // not sent twice
});
test("flush() with an empty buffer does not send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.flush();
assert.equal(sends.length, 0);
});
test("keeps batching after a flush", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("first");
await tick();
buffer.bufferData("second");
await tick();
assert.deepEqual(sends, ["first", "second"]);
});

View File

@@ -17,6 +17,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
@@ -1287,35 +1288,14 @@ async function startSSHSession(event, options) {
});
}
// Data buffering for reduced IPC overhead
let dataBuffer = '';
let flushTimeout = null;
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
const flushBuffer = () => {
if (dataBuffer.length > 0) {
const contents = event.sender;
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
dataBuffer = '';
}
flushTimeout = null;
};
const bufferData = (data) => {
dataBuffer += data;
// Immediate flush for large chunks
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
flushBuffer();
} else if (!flushTimeout) {
// Schedule flush
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
}
};
// Coalesce shell output and deliver it to the renderer on the next
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
// so interactive echo isn't held back by the batch interval. A size
// cap still forces an immediate flush for bursts of output.
const { bufferData, flush: flushBuffer } = createPtyOutputBuffer((data) => {
const contents = event.sender;
safeSend(contents, "netcatty:data", { sessionId, data });
});
const sshZmodemSentry = createZmodemSentry({
sessionId,
@@ -1392,10 +1372,8 @@ async function startSSHSession(event, options) {
});
stream.on("close", () => {
// Always flush buffered data regardless of session state
if (flushTimeout) {
clearTimeout(flushTimeout);
}
// Always flush buffered data regardless of session state.
// flushBuffer() cancels any pending scheduled flush internally.
flushBuffer();
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
if (detachX11Forwarding) {

View File

@@ -25,6 +25,7 @@ const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const execFileAsync = promisify(execFile);
@@ -80,51 +81,6 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
* @param {Function} sendFn - called with the accumulated string to deliver
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
*/
function createPtyBuffer(sendFn) {
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
let dataBuffer = '';
let flushTimeout = null;
const flushBuffer = () => {
if (dataBuffer.length > 0) {
sendFn(dataBuffer);
dataBuffer = '';
}
flushTimeout = null;
};
const flush = () => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
flushBuffer();
};
const bufferData = (data) => {
dataBuffer += data;
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTimeout = null;
}
flushBuffer();
} else if (!flushTimeout) {
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
}
};
return { bufferData, flush };
}
/**
* Locate an executable on POSIX systems by name.
*
@@ -454,7 +410,7 @@ function startLocalSession(event, payload) {
});
}
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyOutputBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
@@ -662,7 +618,7 @@ async function startTelnetSession(event, options) {
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
const telnetWebContentsId = event.sender.id;
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyOutputBuffer((data) => {
const contents = electronModule.webContents.fromId(telnetWebContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
@@ -1189,7 +1145,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
// it to scope its stopStream call.
session.logStreamToken = logStreamToken;
const { bufferData, flush } = createPtyBuffer((data) => {
const { bufferData, flush } = createPtyOutputBuffer((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
});
@@ -1593,6 +1549,31 @@ function writeToSession(event, payload) {
}
}
/**
* Pause or resume a session's source stream for output back-pressure.
* The renderer asks for this when its write backlog crosses a watermark, so a
* flooding source can't outrun the terminal renderer. Works across session
* kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port —
* all expose pause()/resume().
*/
function setSessionFlowPaused(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
const target = session.stream || session.proc || session.socket || session.serialPort;
if (!target) return;
try {
if (payload.paused) {
target.pause?.();
} else {
target.resume?.();
}
} catch (err) {
if (err?.code !== 'EPIPE' && err?.code !== 'ERR_STREAM_DESTROYED') {
console.warn("Flow control toggle failed", err);
}
}
}
/**
* Resize a session terminal
*/
@@ -1707,6 +1688,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:flow", setSessionFlowPaused);
ipcMain.on("netcatty:close", closeSession);
}
@@ -1892,6 +1874,7 @@ module.exports = {
listSerialPorts,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
cleanupAllSessions,
getDefaultShell,

View File

@@ -669,6 +669,9 @@ const api = {
resizeSession: (sessionId, cols, rows) => {
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
},
setSessionFlowPaused: (sessionId, paused) => {
ipcRenderer.send("netcatty:flow", { sessionId, paused: Boolean(paused) });
},
closeSession: (sessionId) => {
ipcRenderer.send("netcatty:close", { sessionId });
},

View File

@@ -3,11 +3,16 @@ import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import unusedImports from "eslint-plugin-unused-imports";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
export default [
js.configs.recommended,
// The recommended preset has no file scope of its own, so scope it off all of
// electron/ — that main-process tree is historically unlinted. The bridges
// get a focused rule set in the dedicated block at the end of this config;
// every other electron/ file matches no config and stays unlinted as before.
{ ...js.configs.recommended, ignores: ["electron/**"] },
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
ignores: ["node_modules/**", "dist/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
},
{
files: ["**/*.{ts,tsx}"],
@@ -168,4 +173,26 @@ export default [
],
},
},
{
// Electron main-process bridges are CommonJS and were historically excluded
// from linting. Lint them for undefined references only — the cheap,
// high-value guard against e.g. a removed variable still referenced
// elsewhere. (The TS config disables no-undef because the type-checker
// already covers it there; these .cjs files have no such safety net.)
files: ["electron/bridges/**/*.cjs"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "commonjs",
globals: globals.node,
},
linterOptions: {
// Only no-undef is enabled here, so pre-existing eslint-disable comments
// for other rules (no-console, no-control-regex, …) would all report as
// "unused". Don't flag them — they stay valid for future rule additions.
reportUnusedDisableDirectives: "off",
},
rules: {
"no-undef": "error",
},
},
];

1
global.d.ts vendored
View File

@@ -323,6 +323,7 @@ declare global {
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
writeToSession(sessionId: string, data: string, options?: { automated?: boolean }): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
setSessionFlowPaused(sessionId: string, paused: boolean): void;
closeSession(sessionId: string): void;
// ZMODEM file transfer
onZmodemEvent?(

View File

@@ -13,6 +13,8 @@ export const STORAGE_KEY_UI_FONT_FAMILY = 'netcatty_ui_font_family_v1';
export const STORAGE_KEY_SYNC = 'netcatty_sync_v1';
export const STORAGE_KEY_TERM_THEME = 'netcatty_term_theme_v1';
export const STORAGE_KEY_TERM_FOLLOW_APP_THEME = 'netcatty_term_follow_app_theme_v1';
export const STORAGE_KEY_TERM_THEME_DARK = 'netcatty_term_theme_dark_v1';
export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';