Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 |
15
App.tsx
15
App.tsx
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal 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" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
61
components/ai/claudeConfigEnv.test.ts
Normal file
61
components/ai/claudeConfigEnv.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
49
components/terminal/autocomplete/snippetCompleter.ts
Normal file
49
components/terminal/autocomplete/snippetCompleter.ts
Normal 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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
19
components/terminal/completionEngineSnippets.test.ts
Normal file
19
components/terminal/completionEngineSnippets.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
48
components/terminal/snippetCompleter.test.ts
Normal file
48
components/terminal/snippetCompleter.test.ts
Normal 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({})], {}), []);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface SyncPayload {
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
followAppTerminalTheme?: boolean;
|
||||
terminalThemeDark?: string;
|
||||
terminalThemeLight?: string;
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
50
electron/bridges/ai/claudeAuth.cjs
Normal file
50
electron/bridges/ai/claudeAuth.cjs
Normal 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 };
|
||||
56
electron/bridges/ai/claudeAuth.test.cjs
Normal file
56
electron/bridges/ai/claudeAuth.test.cjs
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user