Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 |
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,
|
||||
|
||||
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
}, []);
|
||||
|
||||
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
@@ -52,6 +51,7 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
@@ -70,7 +70,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
@@ -299,7 +299,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const terminalLogDataRef = useRef("");
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
@@ -320,21 +320,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(data);
|
||||
if (!replaySafeData) return;
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}, []);
|
||||
|
||||
const finalizeTerminalLogData = useCallback(() => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
||||
if (replaySafeData) {
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}
|
||||
return terminalLogDataRef.current;
|
||||
return connectionLogBufferRef.current.toString();
|
||||
}, []);
|
||||
|
||||
const writeLocalTerminalData = useCallback((data: string) => {
|
||||
@@ -389,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
||||
// xterm runtime (and a few effects here) can drive the hook without making
|
||||
// Terminal re-render on every suggestion update.
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -541,31 +538,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
||||
// its state updates don't re-render this component (see render below).
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux");
|
||||
const autocompleteSettings = terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined;
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
@@ -640,9 +625,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
autocompleteCloseRef.current?.();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
}, [isVisible]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
@@ -940,7 +925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
terminalLogDataRef.current = "";
|
||||
connectionLogBufferRef.current.reset();
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
@@ -1598,10 +1583,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
// Broadcast the exact bytes the active session receives so peers mirror it,
|
||||
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
|
||||
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
|
||||
// line-by-line on peers, since handleBroadcastInput writes bytes directly
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
}
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
@@ -2384,29 +2380,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
{/* Autocomplete — owns the hook + popup in its own component so
|
||||
suggestion/selection updates don't re-render Terminal. Mounted
|
||||
unconditionally; it gates the popup on `visible` internally. */}
|
||||
<TerminalAutocomplete
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={host.protocol}
|
||||
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
|
||||
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
|
||||
snippets={snippets}
|
||||
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
|
||||
visible={isVisible}
|
||||
themeColors={effectiveTheme.colors}
|
||||
containerRef={containerRef}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
keyEventRef={autocompleteKeyEventRef}
|
||||
inputRef={autocompleteInputRef}
|
||||
repositionRef={autocompleteRepositionRef}
|
||||
closeRef={autocompleteCloseRef}
|
||||
/>
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
118
components/terminal/TerminalAutocomplete.tsx
Normal file
118
components/terminal/TerminalAutocomplete.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import type { ComponentProps, RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import {
|
||||
useTerminalAutocomplete,
|
||||
AutocompletePopup,
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
type PopupProps = ComponentProps<typeof AutocompletePopup>;
|
||||
|
||||
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
|
||||
type HandlerRef<T> = { current: T | undefined };
|
||||
|
||||
interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
getCwd?: () => string | undefined;
|
||||
onAcceptText: (text: string) => void;
|
||||
snippets?: Snippet[];
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
/** Whether this terminal tab is the visible one. */
|
||||
visible: boolean;
|
||||
themeColors: PopupProps["themeColors"];
|
||||
containerRef: PopupProps["containerRef"];
|
||||
searchBarOffset: number;
|
||||
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
|
||||
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the terminal autocomplete hook and renders its popup.
|
||||
*
|
||||
* Kept as its own component so the frequent autocomplete state updates
|
||||
* (suggestions, selection, live-preview navigation) re-render only this small
|
||||
* subtree rather than the whole Terminal component. The hook's handlers are
|
||||
* surfaced back to Terminal through refs so the xterm runtime can call them.
|
||||
*
|
||||
* Must be mounted unconditionally for the terminal session's lifetime: the hook
|
||||
* records command history on Enter and intercepts completion keys even while no
|
||||
* popup is visible. Visibility only gates the rendered popup, not the hook.
|
||||
*/
|
||||
export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
getCwd,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
visible,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
keyEventRef,
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
}: TerminalAutocompleteProps) {
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
protocol,
|
||||
getCwd,
|
||||
});
|
||||
|
||||
// Surface the handlers for runtime wiring. They have stable identities
|
||||
// (useCallback over refs), so assigning during render is cheap and mirrors
|
||||
// the wiring Terminal did inline before this was extracted.
|
||||
keyEventRef.current = autocomplete.handleKeyEvent;
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Portal to body so the popup escapes the terminal container's overflow.
|
||||
return ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={state.suggestions}
|
||||
selectedIndex={state.selectedIndex}
|
||||
position={state.popupPosition}
|
||||
cursorLineTop={state.popupCursorLineTop}
|
||||
cursorLineBottom={state.popupCursorLineBottom}
|
||||
visible={state.popupVisible}
|
||||
expandUpward={state.expandUpward}
|
||||
themeColors={themeColors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={state.subDirPanels}
|
||||
subDirFocusLevel={state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={searchBarOffset}
|
||||
onDismiss={autocomplete.closePopup}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -47,6 +48,18 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
|
||||
fastTypingThresholdMs: 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether completion work is worth doing — i.e. whether anything would
|
||||
* actually be rendered. With both the popup and ghost text disabled, querying
|
||||
* completions only to discard the result is pure main-thread waste, so callers
|
||||
* skip it entirely.
|
||||
*/
|
||||
export function shouldQueryCompletions(
|
||||
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
|
||||
): boolean {
|
||||
return settings.showPopupMenu || settings.showGhostText;
|
||||
}
|
||||
|
||||
/** Shared empty state to avoid creating new objects on every reset */
|
||||
const EMPTY_STATE: AutocompleteState = Object.freeze({
|
||||
suggestions: [],
|
||||
@@ -100,6 +113,10 @@ interface UseTerminalAutocompleteOptions {
|
||||
protocol?: string;
|
||||
/** Get current working directory (from OSC 7 or other source) */
|
||||
getCwd?: () => string | undefined;
|
||||
/** Custom snippets to surface at the command position */
|
||||
snippets?: Snippet[];
|
||||
/** Accept a snippet — clears typed input then runs it (host-canonical send) */
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
}
|
||||
|
||||
export interface TerminalAutocompleteHandle {
|
||||
@@ -206,7 +223,7 @@ export function getCommandToRecordOnEnter(
|
||||
export function useTerminalAutocomplete(
|
||||
options: UseTerminalAutocompleteOptions,
|
||||
): TerminalAutocompleteHandle {
|
||||
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd } = options;
|
||||
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd, snippets, onAcceptSnippet } = options;
|
||||
const rawSettings: AutocompleteSettings = {
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
...userSettings,
|
||||
@@ -228,6 +245,10 @@ export function useTerminalAutocomplete(
|
||||
settingsRef.current = settings;
|
||||
const onAcceptTextRef = useRef(onAcceptText);
|
||||
onAcceptTextRef.current = onAcceptText;
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
const onAcceptSnippetRef = useRef(onAcceptSnippet);
|
||||
onAcceptSnippetRef.current = onAcceptSnippet;
|
||||
const hostIdRef = useRef(hostId);
|
||||
hostIdRef.current = hostId;
|
||||
const hostOsRef = useRef(hostOs);
|
||||
@@ -640,6 +661,15 @@ export function useTerminalAutocomplete(
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing will be rendered when both the popup and ghost text are off, so
|
||||
// don't run the (potentially expensive) completion query just to throw the
|
||||
// result away. Clear any stale state and bail before touching history,
|
||||
// fig specs, or remote path lookups.
|
||||
if (!shouldQueryCompletions(settingsRef.current)) {
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture version at start — if it changes during async work, discard results
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
@@ -678,6 +708,7 @@ export function useTerminalAutocomplete(
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
cwd,
|
||||
snippets: snippetsRef.current,
|
||||
});
|
||||
|
||||
if (disposedRef.current || version !== fetchVersionRef.current) return;
|
||||
@@ -695,7 +726,8 @@ export function useTerminalAutocomplete(
|
||||
if (settingsRef.current.showGhostText) {
|
||||
const ghost = ghostAddonRef.current;
|
||||
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
|
||||
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
|
||||
// Snippets are popup-only — never used as inline ghost text.
|
||||
const nextSuggestion = completions.find((c) => c.source !== "snippet")?.text ?? null;
|
||||
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
|
||||
if (ghostDecision.type === "show") {
|
||||
ghost?.show(ghostDecision.suggestion, input);
|
||||
@@ -1222,6 +1254,13 @@ export function useTerminalAutocomplete(
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
|
||||
if (selected?.source === "snippet" && selected.snippet) {
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
acceptSnippet(selected.snippet);
|
||||
return false; // consume — run the snippet, not the typed text
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
@@ -1257,8 +1296,11 @@ export function useTerminalAutocomplete(
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const baseline = previewBaselineRef.current;
|
||||
const selected = index >= 0 ? s.suggestions[index] : null;
|
||||
// Snippets aren't literal completions — keep the user's typed text in the
|
||||
// line (the popup detail panel shows the full command instead).
|
||||
const candidate =
|
||||
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
|
||||
selected && selected.source !== "snippet" ? selected.text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
@@ -1278,6 +1320,26 @@ export function useTerminalAutocomplete(
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Accept a snippet: clear the user's typed input, then run it via the
|
||||
* host-canonical send path (onAcceptSnippet). */
|
||||
const acceptSnippet = useCallback((snippet: Snippet) => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (prompt.isAtPrompt && prompt.userInput.length > 0) {
|
||||
const clearSequence = hostOsRef.current === "windows"
|
||||
? "\b".repeat(prompt.userInput.length)
|
||||
: "\x15"; // Ctrl+U (readline kill-line)
|
||||
writeToTerminal(clearSequence);
|
||||
}
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
onAcceptSnippetRef.current?.(snippet);
|
||||
clearState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/**
|
||||
* Insert a suggestion into the terminal.
|
||||
* @param execute If true, also sends \r to execute the command.
|
||||
@@ -1350,9 +1412,13 @@ export function useTerminalAutocomplete(
|
||||
*/
|
||||
const selectSuggestion = useCallback(
|
||||
(suggestion: CompletionSuggestion) => {
|
||||
if (suggestion.source === "snippet" && suggestion.snippet) {
|
||||
acceptSnippet(suggestion.snippet);
|
||||
return;
|
||||
}
|
||||
insertSuggestion(suggestion, false);
|
||||
},
|
||||
[insertSuggestion],
|
||||
[insertSuggestion, acceptSnippet],
|
||||
);
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
|
||||
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);
|
||||
});
|
||||
25
components/terminal/completionGate.test.ts
Normal file
25
components/terminal/completionGate.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
test("queries completions when the popup menu is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("queries completions when ghost text is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips completion work when both popup and ghost text are off", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
98
components/terminal/connectionLogBuffer.test.ts
Normal file
98
components/terminal/connectionLogBuffer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
|
||||
|
||||
test("concatenates appended chunks while under the cap", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("foo");
|
||||
buf.append("bar");
|
||||
buf.append("baz");
|
||||
assert.equal(buf.toString(), "foobarbaz");
|
||||
});
|
||||
|
||||
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
|
||||
const max = 10;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
assert.equal(buf.toString().length, max);
|
||||
});
|
||||
|
||||
test("trims a single chunk larger than the cap to its last maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(5);
|
||||
buf.append("0123456789");
|
||||
assert.equal(buf.toString(), "56789");
|
||||
});
|
||||
|
||||
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(6);
|
||||
buf.append("abcde"); // 5
|
||||
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
|
||||
assert.equal(buf.toString(), "efghij");
|
||||
});
|
||||
|
||||
test("stays correct across many small appends (ring semantics)", () => {
|
||||
const max = 50;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const chunk = `x${i}-`;
|
||||
buf.append(chunk);
|
||||
naive += chunk;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
});
|
||||
|
||||
test("reset clears the buffer", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("hello");
|
||||
buf.reset();
|
||||
assert.equal(buf.toString(), "");
|
||||
buf.append("world");
|
||||
assert.equal(buf.toString(), "world");
|
||||
});
|
||||
|
||||
test("ignores empty appends", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("a");
|
||||
buf.append("");
|
||||
buf.append("b");
|
||||
assert.equal(buf.toString(), "ab");
|
||||
});
|
||||
|
||||
test("keeps the segment count bounded across many tiny appends", () => {
|
||||
// The whole point of the rewrite: trimming must not walk one array entry
|
||||
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
|
||||
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
|
||||
// single-char appends arrive once it's at capacity.
|
||||
const maxChars = 100;
|
||||
const blockSize = 10;
|
||||
const buf = createConnectionLogBuffer(maxChars, blockSize);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
buf.append("x");
|
||||
naive += "x";
|
||||
}
|
||||
assert.ok(
|
||||
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
|
||||
`segmentCount ${buf.segmentCount()} exceeded the bound`,
|
||||
);
|
||||
assert.equal(buf.toString(), naive.slice(-maxChars));
|
||||
});
|
||||
|
||||
test("seals and trims whole blocks with a small blockSize", () => {
|
||||
const buf = createConnectionLogBuffer(10, 4);
|
||||
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
|
||||
});
|
||||
94
components/terminal/connectionLogBuffer.ts
Normal file
94
components/terminal/connectionLogBuffer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A bounded, append-only text buffer that retains only the last `maxChars`
|
||||
* characters — the connection log used for diagnostics/replay.
|
||||
*
|
||||
* The naive implementation (`log += chunk; if (log.length > max) log =
|
||||
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
|
||||
* cap is reached — on the render thread, for every output chunk including each
|
||||
* echoed keystroke.
|
||||
*
|
||||
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
|
||||
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
|
||||
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
|
||||
* the oldest data therefore only ever drops/slices a handful of blocks — never
|
||||
* one array element per append, which would make trim O(number of appends) and
|
||||
* defeat the purpose. Append is amortized O(chunk); the full string is
|
||||
* materialized only on `toString()` (called rarely, on finalize).
|
||||
*/
|
||||
export interface ConnectionLogBuffer {
|
||||
append(chunk: string): void;
|
||||
toString(): string;
|
||||
reset(): void;
|
||||
/**
|
||||
* Number of internal string segments currently retained. Exposed for tests
|
||||
* to assert the bounded-memory / bounded-trim property.
|
||||
*/
|
||||
segmentCount(): number;
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
export function createConnectionLogBuffer(
|
||||
maxChars: number,
|
||||
blockSize: number = DEFAULT_BLOCK_SIZE,
|
||||
): ConnectionLogBuffer {
|
||||
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
|
||||
let tail = ""; // open block currently being filled (newest data)
|
||||
let total = 0; // total retained length across blocks + tail
|
||||
|
||||
const trim = () => {
|
||||
let overflow = total - maxChars;
|
||||
if (overflow <= 0) return;
|
||||
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
|
||||
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
|
||||
while (overflow > 0 && blocks.length > 0) {
|
||||
const head = blocks[0];
|
||||
if (head.length <= overflow) {
|
||||
blocks.shift();
|
||||
total -= head.length;
|
||||
overflow -= head.length;
|
||||
} else {
|
||||
blocks[0] = head.slice(overflow);
|
||||
total -= overflow;
|
||||
overflow = 0;
|
||||
}
|
||||
}
|
||||
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
|
||||
// maxChars); keep its last `maxChars` characters.
|
||||
if (overflow > 0) {
|
||||
tail = tail.slice(overflow);
|
||||
total -= overflow;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
// A single chunk at/over the cap can only contribute its own tail.
|
||||
if (chunk.length >= maxChars) {
|
||||
blocks = [];
|
||||
tail = chunk.slice(chunk.length - maxChars);
|
||||
total = tail.length;
|
||||
return;
|
||||
}
|
||||
tail += chunk;
|
||||
total += chunk.length;
|
||||
if (tail.length >= blockSize) {
|
||||
blocks.push(tail);
|
||||
tail = "";
|
||||
}
|
||||
if (total > maxChars) trim();
|
||||
},
|
||||
toString(): string {
|
||||
return blocks.length > 0 ? blocks.join("") + tail : tail;
|
||||
},
|
||||
reset(): void {
|
||||
blocks = [];
|
||||
tail = "";
|
||||
total = 0;
|
||||
},
|
||||
segmentCount(): number {
|
||||
return blocks.length + (tail.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
syncPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
|
||||
|
||||
/**
|
||||
* Per-connection token for stale-timer detection. The renderer reuses the
|
||||
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
|
||||
) => (() => void) | undefined;
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
/** Pause/resume the source stream for output back-pressure (optional). */
|
||||
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
|
||||
};
|
||||
|
||||
export type PendingAuth = {
|
||||
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
|
||||
}
|
||||
};
|
||||
|
||||
// Output back-pressure. Without this the renderer can't slow a flooding source,
|
||||
// so a busy stream grows the write queue and xterm's buffer unbounded. The
|
||||
// controller tracks bytes received-but-not-yet-rendered and asks the main
|
||||
// process to pause/resume the session's source stream at these watermarks.
|
||||
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
|
||||
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
|
||||
|
||||
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
|
||||
|
||||
const getFlowController = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
): OutputFlowController => {
|
||||
let controller = terminalFlowControllers.get(term);
|
||||
if (!controller) {
|
||||
controller = createOutputFlowController({
|
||||
highWaterMark: FLOW_HIGH_WATER_MARK,
|
||||
lowWaterMark: FLOW_LOW_WATER_MARK,
|
||||
onPause: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
|
||||
},
|
||||
onResume: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
|
||||
},
|
||||
});
|
||||
terminalFlowControllers.set(term, controller);
|
||||
}
|
||||
return controller;
|
||||
};
|
||||
|
||||
const writeTerminalLine = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -268,6 +303,8 @@ const writeSessionData = (
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const flow = getFlowController(ctx, term);
|
||||
flow.received(data.length);
|
||||
enqueueTerminalWrite(term, (done) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
|
||||
@@ -301,6 +338,8 @@ const writeSessionData = (
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
}
|
||||
done();
|
||||
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
|
||||
flow.written(data.length);
|
||||
};
|
||||
|
||||
term.write(displayData, afterWrite);
|
||||
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
// Clear any stale back-pressure accounting from a prior session on this term.
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
@@ -375,6 +416,29 @@ const attachSessionToTerminal = (
|
||||
});
|
||||
};
|
||||
|
||||
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
|
||||
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
|
||||
|
||||
/**
|
||||
* Split a (possibly multi-line) startup command into non-empty lines, dropping
|
||||
* blank/whitespace-only lines but preserving each line's content verbatim — so
|
||||
* a single-line command stays byte-identical to what the user typed (e.g. a
|
||||
* leading space for `HISTCONTROL=ignorespace` is kept). Trailing `\r` from
|
||||
* CRLF input is normalized away.
|
||||
*/
|
||||
export function splitStartupCommandLines(commandText: string): string[] {
|
||||
return String(commandText || "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\r$/, ""))
|
||||
.filter((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Clamp a configured startup-command delay; fall back to the default when unset/invalid. */
|
||||
export function normalizeStartupCommandDelay(raw: number | undefined): number {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : STARTUP_COMMAND_DEFAULT_DELAY_MS;
|
||||
return Math.max(0, Math.min(STARTUP_COMMAND_MAX_DELAY_MS, value));
|
||||
}
|
||||
|
||||
const scheduleStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -386,24 +450,65 @@ const scheduleStartupCommand = (
|
||||
|
||||
ctx.hasRunStartupCommandRef.current = true;
|
||||
const scheduledSessionId = id;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const delayMs = normalizeStartupCommandDelay(settings?.startupCommandDelayMs);
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const sessionIsCurrent = () =>
|
||||
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
|
||||
|
||||
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
|
||||
// Enter and no line-splitting — unchanged behavior.
|
||||
if (ctx.noAutoRun) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (!sessionIsCurrent()) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
|
||||
onSettled?.();
|
||||
}, delayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-run: send each non-empty line in sequence, waiting delayMs before the
|
||||
// first and between each, so a line runs inside any sub-shell opened by a
|
||||
// previous line (e.g. `sudo -i` then `alias ...`).
|
||||
const lines = splitStartupCommandLines(commandToRun);
|
||||
if (lines.length === 0) {
|
||||
onSettled?.();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const runNext = () => {
|
||||
if (cancelled) return;
|
||||
if (!sessionIsCurrent()) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
const suffix = ctx.noAutoRun ? "" : "\r";
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
|
||||
automated: true,
|
||||
});
|
||||
if (!ctx.noAutoRun) {
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, commandToRun);
|
||||
const line = lines[index];
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
|
||||
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
index += 1;
|
||||
if (index < lines.length) {
|
||||
timeoutId = setTimeout(runNext, delayMs);
|
||||
} else {
|
||||
onSettled?.();
|
||||
}
|
||||
onSettled?.();
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
return () => clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(runNext, delayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
};
|
||||
|
||||
const runDistroDetection = async (
|
||||
@@ -1204,6 +1309,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
});
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
writeSessionData(ctx, term, chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
|
||||
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createOutputFlowController } from "./outputFlowController.ts";
|
||||
|
||||
function make(high = 100, low = 30) {
|
||||
const events: string[] = [];
|
||||
const controller = createOutputFlowController({
|
||||
highWaterMark: high,
|
||||
lowWaterMark: low,
|
||||
onPause: () => events.push("pause"),
|
||||
onResume: () => events.push("resume"),
|
||||
});
|
||||
return { controller, events };
|
||||
}
|
||||
|
||||
test("does not pause while below the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(50);
|
||||
controller.received(49); // 99 < 100
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("pauses once when crossing the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(60);
|
||||
controller.received(60); // 120 >= 100 -> pause
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
// Further received while already paused must not re-fire pause.
|
||||
controller.received(100);
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
});
|
||||
|
||||
test("resumes once when draining to at/below the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(50); // 70 still > 30, no resume
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
controller.written(50); // 20 <= 30 -> resume
|
||||
assert.deepEqual(events, ["pause", "resume"]);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("does not resume when still above the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(80); // 40 > 30
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
});
|
||||
|
||||
test("never lets pending go negative", () => {
|
||||
const { controller } = make(100, 30);
|
||||
controller.received(10);
|
||||
controller.written(50); // over-written
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
});
|
||||
|
||||
test("supports repeated pause/resume cycles", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(120); // resume (0 <= 30)
|
||||
controller.received(120); // pause again
|
||||
controller.written(120); // resume again
|
||||
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
|
||||
});
|
||||
|
||||
test("reset clears state without firing callbacks", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.reset();
|
||||
assert.equal(controller.isPaused(), false);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, ["pause"]); // reset itself is silent
|
||||
// A fresh cycle works after reset.
|
||||
controller.received(120);
|
||||
assert.deepEqual(events, ["pause", "pause"]);
|
||||
});
|
||||
|
||||
test("ignores non-positive amounts", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(0);
|
||||
controller.written(0);
|
||||
controller.received(-5);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
73
components/terminal/runtime/outputFlowController.ts
Normal file
73
components/terminal/runtime/outputFlowController.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Watermark-based flow control for terminal output.
|
||||
*
|
||||
* SSH/PTY output has no back-pressure by default: the source streams as fast as
|
||||
* it can, the main process forwards it over IPC, and the renderer queues every
|
||||
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
|
||||
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
|
||||
* buffer grow without bound — memory climbs and the whole UI, typing included,
|
||||
* janks.
|
||||
*
|
||||
* This tracks bytes that have been received but not yet acknowledged by xterm's
|
||||
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
|
||||
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
|
||||
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
|
||||
* backlog hovers near zero, so this never engages.
|
||||
*/
|
||||
export interface OutputFlowController {
|
||||
/** Account bytes handed to xterm (call when a chunk is received). */
|
||||
received(bytes: number): void;
|
||||
/** Account bytes whose xterm write callback has fired. */
|
||||
written(bytes: number): void;
|
||||
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
|
||||
reset(): void;
|
||||
pendingBytes(): number;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
export interface OutputFlowControllerOptions {
|
||||
highWaterMark: number;
|
||||
lowWaterMark: number;
|
||||
/** Asked to pause the source when the backlog crosses the high watermark. */
|
||||
onPause: () => void;
|
||||
/** Asked to resume the source when the backlog drains to the low watermark. */
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
export function createOutputFlowController(
|
||||
options: OutputFlowControllerOptions,
|
||||
): OutputFlowController {
|
||||
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
|
||||
let pending = 0;
|
||||
let paused = false;
|
||||
|
||||
return {
|
||||
received(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending += bytes;
|
||||
if (!paused && pending >= highWaterMark) {
|
||||
paused = true;
|
||||
onPause();
|
||||
}
|
||||
},
|
||||
written(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending -= bytes;
|
||||
if (pending < 0) pending = 0;
|
||||
if (paused && pending <= lowWaterMark) {
|
||||
paused = false;
|
||||
onResume();
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
pending = 0;
|
||||
paused = false;
|
||||
},
|
||||
pendingBytes(): number {
|
||||
return pending;
|
||||
},
|
||||
isPaused(): boolean {
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
|
||||
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
|
||||
*
|
||||
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
|
||||
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
|
||||
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
|
||||
* adds that whole interval as latency to interactive echo — every keystroke
|
||||
* round-trips through the buffer and waits out the timer before it can paint.
|
||||
* Turn-based flushing coalesces only the data that has already arrived in the
|
||||
* current turn, so a single echoed keystroke is forwarded almost immediately
|
||||
* while bursts of output still collapse into one send.
|
||||
*
|
||||
* A byte cap still forces an immediate, synchronous flush so a flood of output
|
||||
* can't grow the buffer without bound between turns.
|
||||
*
|
||||
* @param {(data: string) => void} sendFn delivers an accumulated batch
|
||||
* @param {{ maxBufferSize?: number }} [options]
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyOutputBuffer(sendFn, options = {}) {
|
||||
const maxBufferSize = options.maxBufferSize ?? 16384; // 16KB
|
||||
|
||||
let dataBuffer = "";
|
||||
let scheduled = null;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (scheduled) {
|
||||
clearImmediate(scheduled);
|
||||
scheduled = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flushNow = () => {
|
||||
scheduled = null;
|
||||
if (dataBuffer.length > 0) {
|
||||
const pending = dataBuffer;
|
||||
dataBuffer = "";
|
||||
sendFn(pending);
|
||||
}
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= maxBufferSize) {
|
||||
// Large enough to ship right now — don't wait for the turn flush.
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
} else if (!scheduled) {
|
||||
scheduled = setImmediate(flushNow);
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
module.exports = { createPtyOutputBuffer };
|
||||
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
/** Resolve after one event-loop turn (immediates have run). */
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("coalesces data buffered within the same turn into a single send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("a");
|
||||
buffer.bufferData("b");
|
||||
buffer.bufferData("c");
|
||||
|
||||
// Nothing is sent synchronously while still in the same turn.
|
||||
assert.equal(sends.length, 0);
|
||||
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["abc"]);
|
||||
});
|
||||
|
||||
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("x");
|
||||
|
||||
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
|
||||
// immediate turn. Turn-based flushing must have delivered it by now.
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["x"]);
|
||||
});
|
||||
|
||||
test("flushes immediately and synchronously once the size cap is reached", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
|
||||
maxBufferSize: 4,
|
||||
});
|
||||
|
||||
buffer.bufferData("ab");
|
||||
assert.equal(sends.length, 0); // under cap, still pending
|
||||
|
||||
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
|
||||
|
||||
// Cap flush happens synchronously, without waiting for the turn.
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
|
||||
// The pending turn flush must have been cancelled — no empty/duplicate send.
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
});
|
||||
|
||||
test("flush() forces a synchronous send and cancels the pending turn", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("hello");
|
||||
buffer.flush();
|
||||
|
||||
assert.deepEqual(sends, ["hello"]);
|
||||
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["hello"]); // not sent twice
|
||||
});
|
||||
|
||||
test("flush() with an empty buffer does not send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.flush();
|
||||
|
||||
assert.equal(sends.length, 0);
|
||||
});
|
||||
|
||||
test("keeps batching after a flush", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("first");
|
||||
await tick();
|
||||
|
||||
buffer.bufferData("second");
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["first", "second"]);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
@@ -1287,35 +1288,14 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Data buffering for reduced IPC overhead
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
// Schedule flush
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
// Coalesce shell output and deliver it to the renderer on the next
|
||||
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
|
||||
// so interactive echo isn't held back by the batch interval. A size
|
||||
// cap still forces an immediate flush for bursts of output.
|
||||
const { bufferData, flush: flushBuffer } = createPtyOutputBuffer((data) => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
const sshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
@@ -1392,10 +1372,8 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
// Always flush buffered data regardless of session state.
|
||||
// flushBuffer() cancels any pending scheduled flush internally.
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||||
if (detachX11Forwarding) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -80,51 +81,6 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on POSIX systems by name.
|
||||
*
|
||||
@@ -454,7 +410,7 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -662,7 +618,7 @@ async function startTelnetSession(event, options) {
|
||||
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1189,7 +1145,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
// it to scope its stopStream call.
|
||||
session.logStreamToken = logStreamToken;
|
||||
|
||||
const { bufferData, flush } = createPtyBuffer((data) => {
|
||||
const { bufferData, flush } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1593,6 +1549,31 @@ function writeToSession(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause or resume a session's source stream for output back-pressure.
|
||||
* The renderer asks for this when its write backlog crosses a watermark, so a
|
||||
* flooding source can't outrun the terminal renderer. Works across session
|
||||
* kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port —
|
||||
* all expose pause()/resume().
|
||||
*/
|
||||
function setSessionFlowPaused(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
const target = session.stream || session.proc || session.socket || session.serialPort;
|
||||
if (!target) return;
|
||||
try {
|
||||
if (payload.paused) {
|
||||
target.pause?.();
|
||||
} else {
|
||||
target.resume?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.code !== 'EPIPE' && err?.code !== 'ERR_STREAM_DESTROYED') {
|
||||
console.warn("Flow control toggle failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a session terminal
|
||||
*/
|
||||
@@ -1707,6 +1688,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:flow", setSessionFlowPaused);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
}
|
||||
|
||||
@@ -1892,6 +1874,7 @@ module.exports = {
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
cleanupAllSessions,
|
||||
getDefaultShell,
|
||||
|
||||
@@ -669,6 +669,9 @@ const api = {
|
||||
resizeSession: (sessionId, cols, rows) => {
|
||||
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
||||
},
|
||||
setSessionFlowPaused: (sessionId, paused) => {
|
||||
ipcRenderer.send("netcatty:flow", { sessionId, paused: Boolean(paused) });
|
||||
},
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
|
||||
@@ -3,11 +3,16 @@ import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
// The recommended preset has no file scope of its own, so scope it off all of
|
||||
// electron/ — that main-process tree is historically unlinted. The bridges
|
||||
// get a focused rule set in the dedicated block at the end of this config;
|
||||
// every other electron/ file matches no config and stays unlinted as before.
|
||||
{ ...js.configs.recommended, ignores: ["electron/**"] },
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
@@ -168,4 +173,26 @@ export default [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Electron main-process bridges are CommonJS and were historically excluded
|
||||
// from linting. Lint them for undefined references only — the cheap,
|
||||
// high-value guard against e.g. a removed variable still referenced
|
||||
// elsewhere. (The TS config disables no-undef because the type-checker
|
||||
// already covers it there; these .cjs files have no such safety net.)
|
||||
files: ["electron/bridges/**/*.cjs"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "commonjs",
|
||||
globals: globals.node,
|
||||
},
|
||||
linterOptions: {
|
||||
// Only no-undef is enabled here, so pre-existing eslint-disable comments
|
||||
// for other rules (no-console, no-control-regex, …) would all report as
|
||||
// "unused". Don't flag them — they stay valid for future rule additions.
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -323,6 +323,7 @@ declare global {
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string, options?: { automated?: boolean }): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
setSessionFlowPaused(sessionId: string, paused: boolean): void;
|
||||
closeSession(sessionId: string): void;
|
||||
// ZMODEM file transfer
|
||||
onZmodemEvent?(
|
||||
|
||||
@@ -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