Compare commits

...

5 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
39 changed files with 1340 additions and 150 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

@@ -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

@@ -1583,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
@@ -2381,6 +2392,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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}

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

@@ -6,6 +6,7 @@ import {
AutocompletePopup,
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
type PopupProps = ComponentProps<typeof AutocompletePopup>;
@@ -21,6 +22,8 @@ interface TerminalAutocompleteProps {
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"];
@@ -54,6 +57,8 @@ export function TerminalAutocomplete({
protocol,
getCwd,
onAcceptText,
snippets,
onAcceptSnippet,
visible,
themeColors,
containerRef,
@@ -70,6 +75,8 @@ export function TerminalAutocomplete({
hostOs,
settings,
onAcceptText,
snippets,
onAcceptSnippet,
protocol,
getCwd,
});

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";
@@ -112,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 {
@@ -218,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,
@@ -240,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);
@@ -699,6 +708,7 @@ export function useTerminalAutocomplete(
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
cwd,
snippets: snippetsRef.current,
});
if (disposedRef.current || version !== fetchVersionRef.current) return;
@@ -716,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);
@@ -1243,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;
@@ -1278,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,
@@ -1299,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.
@@ -1371,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

@@ -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

@@ -416,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,
@@ -427,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 (

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

@@ -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';