Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d1cf1696 | ||
|
|
5be9bb58df | ||
|
|
cab4fc36ab | ||
|
|
53d3e05bb4 | ||
|
|
0c4de74c84 | ||
|
|
2a4feea40f | ||
|
|
faa90e1aa5 | ||
|
|
1aa96c3490 | ||
|
|
0e80955e96 | ||
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 |
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',
|
||||
@@ -1893,6 +1900,18 @@ const en: Messages = {
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.style': 'Protocol style',
|
||||
'ai.providers.style.anthropic': 'Anthropic-compatible',
|
||||
'ai.providers.style.openai': 'OpenAI-compatible',
|
||||
'ai.providers.style.google': 'Google-compatible',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
|
||||
'ai.providers.icon.change': 'Change icon',
|
||||
'ai.providers.icon.upload': 'Upload image',
|
||||
'ai.providers.icon.reset': 'Reset',
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
@@ -1945,6 +1964,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
|
||||
@@ -2015,6 +2041,8 @@ const en: Messages = {
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
|
||||
'ai.chat.selectProvider': 'Select provider',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
|
||||
@@ -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': 'Вставить файл',
|
||||
@@ -1925,6 +1933,18 @@ const ru: Messages = {
|
||||
'ai.providers.remove': 'Удалить',
|
||||
'ai.providers.name': 'Отображаемое имя',
|
||||
'ai.providers.name.placeholder': 'например, Мой провайдер',
|
||||
'ai.providers.style': 'Стиль протокола',
|
||||
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
|
||||
'ai.providers.style.openai': 'Совместимый с OpenAI',
|
||||
'ai.providers.style.google': 'Совместимый с Google',
|
||||
'ai.providers.style.inherited': 'авто',
|
||||
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
|
||||
'ai.providers.icon.change': 'Изменить иконку',
|
||||
'ai.providers.icon.upload': 'Загрузить изображение',
|
||||
'ai.providers.icon.reset': 'Сбросить',
|
||||
'ai.providers.icon.close': 'Свернуть',
|
||||
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
|
||||
'ai.providers.apiKey': 'API-ключ',
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
@@ -1977,6 +1997,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
|
||||
@@ -2047,6 +2074,8 @@ const ru: Messages = {
|
||||
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
|
||||
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
|
||||
'ai.chat.noModel': 'Нет модели',
|
||||
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
|
||||
'ai.chat.selectProvider': 'Выберите провайдера',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
|
||||
@@ -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': '粘贴文件',
|
||||
@@ -1901,6 +1909,18 @@ const zhCN: Messages = {
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.style': '协议风格',
|
||||
'ai.providers.style.anthropic': 'Anthropic 兼容',
|
||||
'ai.providers.style.openai': 'OpenAI 兼容',
|
||||
'ai.providers.style.google': 'Google 兼容',
|
||||
'ai.providers.style.inherited': '默认',
|
||||
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
|
||||
'ai.providers.icon.change': '修改图标',
|
||||
'ai.providers.icon.upload': '上传图片',
|
||||
'ai.providers.icon.reset': '恢复默认',
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
@@ -1953,6 +1973,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
|
||||
@@ -2023,6 +2050,8 @@ const zhCN: Messages = {
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
|
||||
'ai.chat.selectProvider': '选择提供商',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
|
||||
20
application/state/aiStateEvents.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Same-window AI-state-changed event plumbing.
|
||||
*
|
||||
* `localStorage` writes only emit `storage` events in *other* windows; the
|
||||
* window doing the write never gets notified. That's a problem for code
|
||||
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
|
||||
* apply): without a manual nudge, mounted components keep showing stale
|
||||
* AI state until reload.
|
||||
*
|
||||
* Both the dispatcher and `useAIState`'s listener live here so non-React
|
||||
* call sites (sync, IPC handlers, etc.) can fire the event without
|
||||
* pulling in the hook.
|
||||
*/
|
||||
|
||||
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
export function emitAIStateChanged(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
@@ -61,17 +62,14 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -326,6 +324,20 @@ export function useAIState() {
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
// Per-agent provider override: remembers which provider config each agent
|
||||
// should bind to. Falls back to the global `activeProviderId` when an agent
|
||||
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
|
||||
// a Claude/Codex run continues on its existing provider.
|
||||
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
|
||||
);
|
||||
// Mirror for non-functional reads inside removeProvider — needed to know
|
||||
// which agents were bound to the deleted provider so we can also drop
|
||||
// their saved model ids (those ids belonged to the now-missing provider).
|
||||
const agentProviderMapRef = useRef(agentProviderMap);
|
||||
useEffect(() => {
|
||||
agentProviderMapRef.current = agentProviderMap;
|
||||
}, [agentProviderMap]);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
@@ -413,6 +425,21 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
// Empty string clears the per-agent override and lets the agent fall
|
||||
// back to the global `activeProviderId`.
|
||||
const next = { ...prev };
|
||||
if (providerId) {
|
||||
next[agentId] = providerId;
|
||||
} else {
|
||||
delete next[agentId];
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -600,6 +627,9 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
|
||||
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
@@ -1080,6 +1110,41 @@ export function useAIState() {
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
// Drop per-agent overrides pointing at this provider plus the saved
|
||||
// model id for those agents — the id belonged to the now-missing
|
||||
// provider, so feeding it to the fallback provider would just send
|
||||
// a model name that target doesn't recognize.
|
||||
const orphanedAgents = Object.keys(agentProviderMapRef.current)
|
||||
.filter((agentId) => agentProviderMapRef.current[agentId] === id);
|
||||
if (orphanedAgents.length > 0) {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
const next: Record<string, string> = {};
|
||||
let changed = false;
|
||||
for (const agentId of Object.keys(prev)) {
|
||||
if (prev[agentId] === id) {
|
||||
changed = true;
|
||||
} else {
|
||||
next[agentId] = prev[agentId];
|
||||
}
|
||||
}
|
||||
if (!changed) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
setAgentModelMapRaw(prev => {
|
||||
let changed = false;
|
||||
const next: Record<string, string> = { ...prev };
|
||||
for (const agentId of orphanedAgents) {
|
||||
if (agentId in next) {
|
||||
delete next[agentId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
@@ -1123,6 +1188,9 @@ export function useAIState() {
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
// Per-agent provider override (falls back to activeProviderId when unset)
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
|
||||
// Web search
|
||||
webSearchConfig,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -120,6 +120,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
@@ -135,6 +136,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
commandTimeout: 120,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
});
|
||||
});
|
||||
@@ -201,6 +203,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
commandTimeout: 30,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
webSearchConfig: webSearch,
|
||||
},
|
||||
},
|
||||
@@ -219,9 +222,104 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
// Without this nudge, the apply path writes to localStorage but
|
||||
// `useAIState` (listening for `storage` events) never sees the changes
|
||||
// in the calling window — mounted UI keeps showing pre-sync data.
|
||||
const dispatched: Array<{ type: string; detail: unknown }> = [];
|
||||
const fakeWindow = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent(event: Event) {
|
||||
dispatched.push({
|
||||
type: event.type,
|
||||
detail: (event as CustomEvent).detail,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
try {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
|
||||
const keys = events.map((e) => (e.detail as { key?: string })?.key);
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
|
||||
} finally {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
|
||||
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
|
||||
// Local state has Catty bound to a provider the incoming sync no longer
|
||||
// ships — both the per-agent provider override and the saved model should
|
||||
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
|
||||
// model name) to the wrong endpoint.
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
|
||||
catty: "deepseek-local",
|
||||
codex: "openai-main",
|
||||
}));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
|
||||
catty: "deepseek-v4-flash",
|
||||
codex: "gpt-test",
|
||||
}));
|
||||
|
||||
const syncedProviders = [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
|
||||
];
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: syncedProviders,
|
||||
// Intentionally omit agentProviderMap — exercises the reconcile path.
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
|
||||
{ codex: "openai-main" },
|
||||
);
|
||||
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
|
||||
// Codex's binding stays, so its saved model stays.
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
|
||||
{ codex: "gpt-test" },
|
||||
);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
||||
const localAgents = [
|
||||
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
@@ -43,6 +44,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,
|
||||
@@ -71,6 +74,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
@@ -161,6 +165,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 +191,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,
|
||||
@@ -214,6 +221,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
] as const;
|
||||
|
||||
@@ -309,6 +317,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);
|
||||
@@ -405,6 +417,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
||||
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
@@ -432,6 +446,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));
|
||||
|
||||
@@ -522,6 +538,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
||||
if (ai.webSearchConfig !== undefined) {
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
@@ -532,6 +549,83 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
// Nudge same-window AI state listeners. localStorage writes only fire
|
||||
// `storage` events in *other* windows; without this nudge the open
|
||||
// chat panel keeps showing pre-sync providers/bindings until reload.
|
||||
notifyAIStateAfterSync(ai);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
|
||||
if (!ai) return;
|
||||
// Every AI storage key that `applySyncableSettings` may have touched
|
||||
// gets a same-window nudge. `useAIState` listens for these and refreshes
|
||||
// the corresponding React state by re-reading localStorage.
|
||||
const touched: Array<string> = [];
|
||||
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
// agentProviderMap is *always* potentially mutated because the reconcile
|
||||
// step may have pruned it even if the payload didn't ship one.
|
||||
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
// The reconcile may also have pruned saved models alongside provider
|
||||
// bindings, so always nudge the model map too.
|
||||
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOrphanPerAgentBindings(): void {
|
||||
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
const validIds = new Set(
|
||||
providers
|
||||
.map((p) => p?.id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0),
|
||||
);
|
||||
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
||||
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
||||
let providerChanged = false;
|
||||
let modelChanged = false;
|
||||
const nextProviderMap: Record<string, string> = {};
|
||||
const nextModelMap: Record<string, string> = { ...modelMap };
|
||||
for (const agentId of Object.keys(providerMap)) {
|
||||
const providerId = providerMap[agentId];
|
||||
if (providerId && validIds.has(providerId)) {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
modelChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providerChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
|
||||
}
|
||||
if (modelChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ interface AIChatSidePanelProps {
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
agentProviderMap: Record<string, string>;
|
||||
setAgentProvider: (agentId: string, providerId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
@@ -226,6 +228,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
@@ -562,8 +566,67 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
// Catty Agent honors a per-agent provider/model override from
|
||||
// `agentProviderMap` / `agentModelMap`, falling back to the global active
|
||||
// selection. External ACP agents (Claude/Codex/Copilot) keep their
|
||||
// existing provider plumbing — the user picks them inside the ACP CLI
|
||||
// itself, so a per-agent provider override doesn't apply.
|
||||
const cattyAgentProvider = useMemo(() => {
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
if (overrideId) {
|
||||
const p = providers.find((cfg) => cfg.id === overrideId);
|
||||
if (p) return p;
|
||||
// Override exists but points to a deleted provider — fall through
|
||||
// to the global active selection.
|
||||
}
|
||||
return activeProvider;
|
||||
}, [agentProviderMap, providers, activeProvider]);
|
||||
|
||||
const cattyAgentModelId = useMemo(() => {
|
||||
// Whitespace-only model ids are treated as "no model" everywhere
|
||||
// (picker, send guard, SDK) — normalize at the resolution boundary
|
||||
// so a stored " " never slips through downstream checks.
|
||||
const trim = (s: string | undefined | null): string => (s ?? '').trim();
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
const overrideProvider = overrideId
|
||||
? providers.find((cfg) => cfg.id === overrideId)
|
||||
: undefined;
|
||||
if (overrideProvider) {
|
||||
// Override intact — prefer the per-agent saved model, then the
|
||||
// override provider's defaultModel. Never reach for the global
|
||||
// `activeModelId` here: that id belongs to whichever provider
|
||||
// was globally active, not the one Catty is bound to now.
|
||||
return trim(agentModelMap['catty']) || trim(overrideProvider.defaultModel);
|
||||
}
|
||||
// No override, OR a stale override (the bound provider was deleted):
|
||||
// in either case the saved model id is no longer trustworthy as a
|
||||
// Catty pick, so consult the global active selection instead.
|
||||
return trim(cattyAgentProvider?.defaultModel) || trim(activeModelId);
|
||||
}, [agentModelMap, agentProviderMap, providers, cattyAgentProvider, activeModelId]);
|
||||
|
||||
const effectiveActiveProvider = currentAgentId === 'catty' ? cattyAgentProvider : activeProvider;
|
||||
const effectiveActiveModelId = currentAgentId === 'catty' ? cattyAgentModelId : activeModelId;
|
||||
|
||||
// Catty Agent surfaces its provider picker in the chat input. The list
|
||||
// mirrors what Settings → AI → Providers shows — every configured
|
||||
// provider, regardless of the per-provider `enabled` toggle, so the
|
||||
// user can swap between everything they've set up without first going
|
||||
// back into Settings to flip a switch.
|
||||
const cattyConfiguredProviders = useMemo(
|
||||
() => (currentAgentId === 'catty' ? providers : []),
|
||||
[currentAgentId, providers],
|
||||
);
|
||||
|
||||
const handleAgentProviderModelSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
setAgentProvider(currentAgentId, providerId);
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
},
|
||||
[currentAgentId, setAgentProvider, setAgentModel],
|
||||
);
|
||||
|
||||
const providerDisplayName = effectiveActiveProvider?.name ?? '';
|
||||
const modelDisplayName = effectiveActiveModelId || effectiveActiveProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
@@ -859,8 +922,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// Catty Agent picks up the per-agent provider/model override. External
|
||||
// ACP agents continue to ride the global selection (they wire their
|
||||
// own provider through the CLI).
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
@@ -870,6 +939,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Catty needs a concrete model id — the SDK would otherwise dispatch
|
||||
// an empty string and surface a vague backend error. The chat-input
|
||||
// chip already disables provider rows with no defaultModel, but a
|
||||
// stale binding (e.g. user emptied the provider's defaultModel after
|
||||
// selecting it) can still land here. Trim before checking so
|
||||
// whitespace-only ids (which the picker also treats as empty) don't
|
||||
// sneak past either.
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
@@ -887,8 +973,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -928,8 +1014,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
@@ -948,7 +1034,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1127,6 +1213,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Card } from "./ui/card";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
@@ -861,12 +862,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
{/* Startup Command */}
|
||||
<Input
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
|
||||
@@ -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';
|
||||
@@ -381,6 +382,8 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
agentProviderMap={aiState.agentProviderMap}
|
||||
setAgentProvider={aiState.setAgentProvider}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
@@ -465,9 +468,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 +892,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
toggleSidePanelRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -1101,13 +1102,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 +1851,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 +1880,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 +1898,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, tab);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [resolveSftpHostForTab]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
@@ -1931,6 +1938,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}
|
||||
|
||||
@@ -20,12 +20,37 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
// Slightly wider for the provider picker so the per-row default-model
|
||||
// caption doesn't truncate.
|
||||
const PROVIDER_PICKER_MAX_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* Provider picker payload used by Catty Agent. When set, the model chip
|
||||
* switches to a flat provider list (provider icon + name + the provider's
|
||||
* configured default model as caption) in place of the generic Cpu glyph
|
||||
* + model-preset dropdown. Each provider exposes a single model — its
|
||||
* `defaultModel` — so a two-level menu would be empty noise; picking a
|
||||
* provider implicitly picks its model.
|
||||
*/
|
||||
export interface ProviderSwitcherConfig {
|
||||
/** Every configured provider — Settings-level visibility, not the
|
||||
* `enabled` toggle, since the user expects to swap between everything
|
||||
* they've set up. */
|
||||
providers: ProviderConfig[];
|
||||
/** Currently bound provider id (falls back to providers[0] when missing). */
|
||||
selectedProviderId?: string;
|
||||
/** Currently bound model id under the selected provider. */
|
||||
selectedModelId?: string;
|
||||
/** Fires when the user picks a (providerId, modelId) pair. */
|
||||
onSelect: (providerId: string, modelId: string) => void;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
@@ -64,6 +89,13 @@ interface ChatInputProps {
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
onPermissionModeChange?: (mode: AIPermissionMode) => void;
|
||||
/**
|
||||
* Provider→model two-level picker payload. When provided, replaces the
|
||||
* single-list model dropdown with a provider-aware picker. Used for the
|
||||
* Catty Agent only — external ACP agents (Claude/Codex) keep the
|
||||
* `modelPresets` dropdown because their provider is wired inside the CLI.
|
||||
*/
|
||||
providerSwitcher?: ProviderSwitcherConfig;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
@@ -90,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
providerSwitcher,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -355,10 +388,31 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
// Provider switcher mode (Catty Agent): two-column popover, chip carries
|
||||
// the provider's icon + name + model name. Falls back to the existing
|
||||
// single-list model dropdown for ACP agents.
|
||||
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
|
||||
// Resolve to the actually-bound provider only — no `?? providers[0]`
|
||||
// fallback, since a provider that isn't really bound will still hit the
|
||||
// `!sendActiveProvider` guard at send time. Faking a selection in the
|
||||
// chip would lie about a state the rest of the system treats as empty.
|
||||
const selectedSwitcherProvider = hasProviderSwitcher
|
||||
? providerSwitcher!.providers.find((p) => p.id === providerSwitcher!.selectedProviderId)
|
||||
: undefined;
|
||||
const providerSwitcherChipLabel = hasProviderSwitcher
|
||||
? (selectedSwitcherProvider
|
||||
? (providerSwitcher!.selectedModelId
|
||||
? `${selectedSwitcherProvider.name} · ${providerSwitcher!.selectedModelId}`
|
||||
: selectedSwitcherProvider.name)
|
||||
: t('ai.chat.selectProvider'))
|
||||
: '';
|
||||
const modelLabel = hasProviderSwitcher
|
||||
? providerSwitcherChipLabel
|
||||
: (selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel'));
|
||||
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
|
||||
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const selectedSkillChipClassName =
|
||||
@@ -655,7 +709,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
@@ -664,11 +718,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
<span className="truncate max-w-[82px]">{modelLabel}</span>
|
||||
{hasProviderSwitcher && selectedSwitcherProvider ? (
|
||||
<ProviderIconBadge provider={selectedSwitcherProvider} size="xs" />
|
||||
) : (
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
)}
|
||||
<span className={`truncate ${hasProviderSwitcher ? 'max-w-[180px]' : 'max-w-[82px]'}`}>{modelLabel}</span>
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
@@ -677,12 +735,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
{hasProviderSwitcher ? (
|
||||
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
|
||||
{providerSwitcher!.providers.map((p) => {
|
||||
const isSelected = providerSwitcher!.selectedProviderId === p.id;
|
||||
const defaultModel = p.defaultModel?.trim() ?? '';
|
||||
const hasModel = defaultModel.length > 0;
|
||||
// Rows without a defaultModel are inert — picking
|
||||
// one would save a binding with an empty model id
|
||||
// and produce a confusing model error at send time.
|
||||
// User has to set a defaultModel in Settings first.
|
||||
const disabled = !hasModel;
|
||||
const modelCaption = hasModel
|
||||
? defaultModel
|
||||
: t('ai.chat.noProviderModel');
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
title={disabled ? t('ai.chat.noProviderModel') : undefined}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
providerSwitcher!.onSelect(p.id, defaultModel);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
|
||||
disabled
|
||||
? 'opacity-55 cursor-not-allowed'
|
||||
: 'hover:bg-muted/30 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ProviderIconBadge provider={p} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
|
||||
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
|
||||
{modelCaption}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check size={12} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
return (
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
@@ -647,9 +648,26 @@ export function useAIChatStreaming({
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
// The SDK may still emit this chunk type but we simply ignore it.
|
||||
case 'error': {
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
// Internal SDK reasoning/text state-machine errors (e.g. a
|
||||
// third-party Anthropic-compat backend like DeepSeek's
|
||||
// `-v4-flash` streaming thinking deltas without first emitting
|
||||
// the `reasoning-start` content-block signal) leak through
|
||||
// fullStream once per orphan delta. They're not user-facing
|
||||
// errors — and worse, surfacing one assistant message per
|
||||
// event breaks tool_use/tool_result contiguity on the next
|
||||
// turn, which the Anthropic backend then rejects as
|
||||
// `messages.N: tool_use ids were found without tool_result
|
||||
// blocks immediately after`. Filter them out at the chunk
|
||||
// boundary: drop the placeholder assistant message and keep
|
||||
// accepting subsequent chunks, so the rest of the stream
|
||||
// (real text, tool calls, the genuine `finish`) lands intact.
|
||||
if (isSdkStreamStateError(typedChunk.error)) {
|
||||
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
|
||||
break;
|
||||
}
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -16,8 +18,10 @@ export const ModelSelector: React.FC<{
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
skipTLSVerify?: boolean;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, style, skipTLSVerify }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -25,12 +29,19 @@ export const ModelSelector: React.FC<{
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
// Resolve the wire-protocol family: prefer an explicit style override (set in
|
||||
// the form), then fall back to the providerId-derived default.
|
||||
const resolvedStyle: ProviderStyle = style
|
||||
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
|
||||
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
|
||||
// Anthropic providerId switched to OpenAI style) still hits the right path.
|
||||
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!modelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!modelsEndpoint) return;
|
||||
if (!effectiveModelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
|
||||
@@ -42,16 +53,8 @@ export const ModelSelector: React.FC<{
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
if (providerId === "anthropic") {
|
||||
headers["x-api-key"] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
@@ -70,7 +73,7 @@ export const ModelSelector: React.FC<{
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ProviderCard: React.FC<{
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider icon */}
|
||||
<ProviderIconBadge providerId={provider.providerId} />
|
||||
<ProviderIconBadge provider={provider} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
const ICON_PIXEL_SIZE = 64;
|
||||
const ICON_WEBP_QUALITY = 0.85;
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new Error("Image too large; please use an image under 5 MB.");
|
||||
}
|
||||
const sourceUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("Failed to decode image"));
|
||||
el.src = sourceUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = ICON_PIXEL_SIZE;
|
||||
canvas.height = ICON_PIXEL_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE);
|
||||
const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h);
|
||||
return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY);
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
@@ -14,6 +53,8 @@ export const ProviderConfigForm: React.FC<{
|
||||
onCancel: () => void;
|
||||
}> = ({ provider, onSave, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [form, setForm] = useState<ProviderFormState>({
|
||||
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
|
||||
apiKey: "",
|
||||
@@ -21,13 +62,24 @@ export const ProviderConfigForm: React.FC<{
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
style: provider.style ?? "",
|
||||
iconId: provider.iconId ?? "",
|
||||
iconDataUrl: provider.iconDataUrl ?? "",
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Decrypt and load existing API key on mount
|
||||
useEffect(() => {
|
||||
@@ -62,6 +114,31 @@ export const ProviderConfigForm: React.FC<{
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleIconFileSelect = useCallback(async (file: File | null) => {
|
||||
setIconError(null);
|
||||
if (!file) return;
|
||||
if (!/^image\//.test(file.type)) {
|
||||
setIconError(t("ai.providers.icon.errorType"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await compressIconFileToDataUrl(file);
|
||||
setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" }));
|
||||
} catch (err) {
|
||||
setIconError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name }));
|
||||
}, []);
|
||||
|
||||
const handleResetIcon = useCallback(() => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
@@ -71,12 +148,18 @@ export const ProviderConfigForm: React.FC<{
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
name: trimmedName || defaultName,
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
style: form.style || undefined,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
@@ -87,23 +170,133 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, isCustom]);
|
||||
}, [form, onSave, provider.providerId]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
{/* Name (custom providers only) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
{/* Display: icon + name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIconPicker((v) => !v)}
|
||||
className="group relative shrink-0 rounded-md transition-all hover:brightness-110 hover:ring-2 hover:ring-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-label={t('ai.providers.icon.change')}
|
||||
title={t('ai.providers.icon.change')}
|
||||
>
|
||||
<ProviderIconBadge provider={previewProvider} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
>
|
||||
<Pencil size={9} strokeWidth={2.5} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('ai.providers.name.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showIconPicker && (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 p-2 space-y-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1.5">
|
||||
{BUILTIN_PROVIDER_ICONS.map((icon) => {
|
||||
const isSelected = form.iconId === icon.id && !form.iconDataUrl;
|
||||
return (
|
||||
<button
|
||||
key={icon.id}
|
||||
type="button"
|
||||
onClick={() => (isSelected ? handleResetIcon() : handlePickBuiltin(icon))}
|
||||
title={icon.label}
|
||||
aria-label={icon.label}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md border text-left transition-colors min-w-0",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15"
|
||||
: "border-transparent hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<ProviderIconBadge
|
||||
provider={{ providerId: provider.providerId, name: icon.label, iconId: icon.id }}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-xs text-foreground/85 truncate">{icon.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleIconFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.upload')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetIcon}>
|
||||
<RotateCcw size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.reset')}
|
||||
</Button>
|
||||
{form.iconDataUrl && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('ai.providers.icon.uploadedNote')}</span>
|
||||
)}
|
||||
<div className="ml-auto" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowIconPicker(false)}
|
||||
aria-label={t('ai.providers.icon.close')}
|
||||
title={t('ai.providers.icon.close')}
|
||||
>
|
||||
<X size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.close')}
|
||||
</Button>
|
||||
</div>
|
||||
{iconError && <p className="text-[11px] text-destructive">{iconError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider style */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.style')}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STYLE_OPTIONS.map((style) => {
|
||||
const isSelected = resolvedStyle === style;
|
||||
const isInherited = !form.style && isSelected;
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, style: prev.style === style ? "" : style }))}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-xs border transition-colors",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15 text-foreground"
|
||||
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{t(`ai.providers.style.${style}`)}
|
||||
{isInherited && (
|
||||
<span className="ml-1 text-[9px] text-muted-foreground/70">({t('ai.providers.style.inherited')})</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
@@ -150,6 +343,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
style={resolvedStyle}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,117 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import type { SettingsIconId } from "./types";
|
||||
import { SETTINGS_ICON_PATHS, SETTINGS_ICON_COLORS } from "./types";
|
||||
import {
|
||||
BUILTIN_PROVIDER_ICON_BY_ID,
|
||||
SETTINGS_ICON_PATHS,
|
||||
SETTINGS_ICON_COLORS,
|
||||
} from "./types";
|
||||
|
||||
export const ProviderIconBadge: React.FC<{
|
||||
providerId: SettingsIconId;
|
||||
size?: "sm" | "md";
|
||||
}> = ({ providerId, size = "md" }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden",
|
||||
size === "sm" ? "w-5 h-5" : "w-8 h-8",
|
||||
SETTINGS_ICON_COLORS[providerId],
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/**
|
||||
* Optional ProviderConfig-like shape for per-provider customization. Only the
|
||||
* fields used by the badge are listed so non-provider call sites (Claude/Copilot
|
||||
* agent cards) can still pass a bare `providerId`.
|
||||
*/
|
||||
type ProviderLike = Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl">;
|
||||
|
||||
interface BaseProps {
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
type Props =
|
||||
| (BaseProps & { providerId: SettingsIconId; provider?: undefined })
|
||||
| (BaseProps & { provider: ProviderLike; providerId?: undefined });
|
||||
|
||||
const BADGE_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
const IMG_DIMENSIONS = {
|
||||
xs: "w-2.5 h-2.5",
|
||||
sm: "w-3 h-3",
|
||||
md: "w-4 h-4",
|
||||
} as const;
|
||||
|
||||
const UPLOAD_IMG_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
export const ProviderIconBadge: React.FC<Props> = (props) => {
|
||||
const size = props.size ?? "md";
|
||||
const dim = BADGE_DIMENSIONS[size];
|
||||
|
||||
// Branch 1: user-uploaded data URL — render verbatim, no filter, neutral bg.
|
||||
if (props.provider?.iconDataUrl) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-900/40", dim)}>
|
||||
<img
|
||||
src={props.provider.iconDataUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain", UPLOAD_IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 2: built-in iconId (lobe-icons subset).
|
||||
const iconId = props.provider?.iconId;
|
||||
if (iconId) {
|
||||
const builtin = BUILTIN_PROVIDER_ICON_BY_ID[iconId];
|
||||
if (builtin) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, builtin.bgColor)}>
|
||||
<img
|
||||
src={builtin.path}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain brightness-0 invert", IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch 3: providerId → existing built-in fallback table.
|
||||
const fallbackId: SettingsIconId | undefined =
|
||||
props.providerId ?? (props.provider ? (props.provider.providerId as SettingsIconId) : undefined);
|
||||
if (fallbackId && fallbackId in SETTINGS_ICON_PATHS) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, SETTINGS_ICON_COLORS[fallbackId])}>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[fallbackId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
fallbackId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
IMG_DIMENSIONS[size],
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 4: letter avatar from the provider name.
|
||||
const letter = (props.provider?.name?.trim().charAt(0) ?? "?").toUpperCase();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-600 text-white font-medium",
|
||||
dim,
|
||||
size === "md" ? "text-sm" : size === "sm" ? "text-[10px]" : "text-[9px]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderStyle,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
@@ -79,6 +80,9 @@ export interface ProviderFormState {
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
style: ProviderStyle | ""; // "" means inherit-from-providerId
|
||||
iconId: string; // "" means no built-in pick (fall back to providerId)
|
||||
iconDataUrl: string; // "" means no upload override
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
@@ -175,3 +179,44 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openrouter: "bg-pink-600",
|
||||
custom: "bg-zinc-600",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extra brand icons (lobe-icons subset, MIT) for ProviderConfig.iconId
|
||||
// See public/ai/providers/NOTICE.md for attribution.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuiltinProviderIcon {
|
||||
/** Identifier stored as ProviderConfig.iconId. */
|
||||
id: string;
|
||||
/** Display label shown in the icon picker. */
|
||||
label: string;
|
||||
/** Suggested display name when picking this preset (auto-fills ProviderConfig.name). */
|
||||
name: string;
|
||||
/** Absolute URL of the SVG asset. */
|
||||
path: string;
|
||||
/** Background tint applied behind the monochrome glyph. */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
|
||||
{ id: "anthropic", label: "Anthropic", name: "Anthropic", path: "/ai/providers/anthropic.svg", bgColor: "bg-orange-600" },
|
||||
{ id: "openai", label: "OpenAI", name: "OpenAI", path: "/ai/providers/openai.svg", bgColor: "bg-emerald-600" },
|
||||
{ id: "google", label: "Google", name: "Google", path: "/ai/providers/google.svg", bgColor: "bg-blue-600" },
|
||||
{ id: "ollama", label: "Ollama", name: "Ollama", path: "/ai/providers/ollama.svg", bgColor: "bg-purple-600" },
|
||||
{ id: "openrouter", label: "OpenRouter", name: "OpenRouter", path: "/ai/providers/openrouter.svg", bgColor: "bg-pink-600" },
|
||||
{ id: "deepseek", label: "DeepSeek", name: "DeepSeek", path: "/ai/providers/deepseek.svg", bgColor: "bg-[#4D6BFE]" },
|
||||
{ id: "moonshot", label: "Moonshot", name: "Moonshot", path: "/ai/providers/moonshot.svg", bgColor: "bg-zinc-800" },
|
||||
{ id: "kimi", label: "Kimi", name: "Kimi", path: "/ai/providers/kimi.svg", bgColor: "bg-zinc-800" },
|
||||
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
|
||||
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
|
||||
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
|
||||
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
|
||||
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
|
||||
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },
|
||||
{ id: "perplexity", label: "Perplexity", name: "Perplexity", path: "/ai/providers/perplexity.svg", bgColor: "bg-[#1F8A8C]" },
|
||||
{ id: "groq", label: "Groq", name: "Groq", path: "/ai/providers/groq.svg", bgColor: "bg-[#F55036]" },
|
||||
{ id: "huggingface", label: "Hugging Face", name: "Hugging Face", path: "/ai/providers/huggingface.svg", bgColor: "bg-[#FF9D00]" },
|
||||
];
|
||||
|
||||
export const BUILTIN_PROVIDER_ICON_BY_ID: Record<string, BuiltinProviderIcon> =
|
||||
Object.fromEntries(BUILTIN_PROVIDER_ICONS.map((icon) => [icon.id, icon]));
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
@@ -233,6 +235,7 @@ export interface SyncPayload {
|
||||
commandTimeout?: number;
|
||||
maxIterations?: number;
|
||||
agentModelMap?: Record<string, string>;
|
||||
agentProviderMap?: Record<string, string>;
|
||||
webSearchConfig?: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -912,7 +912,36 @@ function execViaChannel(sshClient, command, options) {
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
sshClient.exec(command, (err, execStream) => {
|
||||
// Register a *pending* cancellation marker synchronously, before
|
||||
// `sshClient.exec` opens the channel. Without this, a cancel that
|
||||
// arrives while we're still waiting on `sshClient.exec`'s callback
|
||||
// finds nothing in `activePtyExecs` to act on — the channel then
|
||||
// opens, the real marker registers, and the command runs to
|
||||
// completion despite the user already cancelling. The pending
|
||||
// marker latches `cancelled = true`; when the callback fires we
|
||||
// check the latch and short-circuit instead of starting work.
|
||||
const pendingMarker = `__NCMCP_CH_PENDING_${Date.now().toString(36)}_${crypto.randomBytes(8).toString('hex')}__`;
|
||||
let cancelled = false;
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(pendingMarker, {
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => { cancelled = true; },
|
||||
cleanup: () => { /* nothing pending to clean up before channel opens */ },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sshClient.exec(command, (err, execStream) => {
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(pendingMarker);
|
||||
}
|
||||
if (cancelled) {
|
||||
if (execStream) {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
}
|
||||
resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
resolve({ ok: false, error: err.message });
|
||||
return;
|
||||
@@ -963,6 +992,18 @@ function execViaChannel(sshClient, command, options) {
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Rare path: `sshClient.exec` itself synchronously throws (e.g.
|
||||
// because the underlying ssh2 client was destroyed between the
|
||||
// session lookup and now). Drop the pending marker so it doesn't
|
||||
// leak in `activePtyExecs`, and resolve as a normal failure
|
||||
// result instead of letting the Promise reject — the tool layer
|
||||
// expects `{ ok, error }` shape, not a thrown error.
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(pendingMarker);
|
||||
}
|
||||
resolve({ ok: false, error: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
resolveEffectiveShellKind,
|
||||
execViaChannel,
|
||||
} = require("./ptyExec.cjs");
|
||||
|
||||
test("uses PowerShell wrapping when a session with no confirmed shell sees a PowerShell prompt", () => {
|
||||
@@ -107,3 +108,84 @@ test("does not misclassify command output that happens to contain 'PS'", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PSO>"), "posix");
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "ZIPS>"), "posix");
|
||||
});
|
||||
|
||||
test("execViaChannel registers a pending-cancel marker before the SSH channel opens", () => {
|
||||
// Regression for the IPC-transit race surfaced by codex on #1101
|
||||
// problem 3: if `cancelPtyExecsForSession` runs while we're still
|
||||
// waiting on `sshClient.exec`'s callback, the cancel finds nothing in
|
||||
// `activePtyExecs` and the channel opens anyway. The fix registers a
|
||||
// pending marker synchronously so the cancel has something to act on.
|
||||
const track = new Map();
|
||||
let execCallback;
|
||||
const fakeClient = {
|
||||
exec(_command, callback) {
|
||||
// Capture but do not invoke yet — simulates the channel-open
|
||||
// delay where the race window lives.
|
||||
execCallback = callback;
|
||||
},
|
||||
};
|
||||
void execViaChannel(fakeClient, "echo hi", {
|
||||
trackForCancellation: track,
|
||||
chatSessionId: "chat-1",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.equal(track.size, 1, "pending marker should be registered before the channel opens");
|
||||
const entry = Array.from(track.values())[0];
|
||||
assert.equal(entry.chatSessionId, "chat-1");
|
||||
assert.equal(typeof entry.cancel, "function");
|
||||
// Drain the callback so the timeout the test set doesn't fire later.
|
||||
execCallback(new Error("test teardown"), null);
|
||||
});
|
||||
|
||||
test("execViaChannel drops the pending marker and resolves cleanly when sshClient.exec throws synchronously", async () => {
|
||||
const track = new Map();
|
||||
const fakeClient = {
|
||||
exec() {
|
||||
throw new Error("client destroyed");
|
||||
},
|
||||
};
|
||||
const result = await execViaChannel(fakeClient, "echo hi", {
|
||||
trackForCancellation: track,
|
||||
chatSessionId: "chat-throw",
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, "client destroyed");
|
||||
assert.equal(track.size, 0, "pending marker must be removed even on sync throw");
|
||||
});
|
||||
|
||||
test("execViaChannel short-circuits when cancel fires before the SSH channel opens", async () => {
|
||||
const track = new Map();
|
||||
let execCallback;
|
||||
const fakeClient = {
|
||||
exec(_command, callback) {
|
||||
execCallback = callback;
|
||||
},
|
||||
};
|
||||
const resultPromise = execViaChannel(fakeClient, "sleep 5", {
|
||||
trackForCancellation: track,
|
||||
chatSessionId: "chat-2",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
// Cancel while still waiting for the channel-open callback.
|
||||
assert.equal(track.size, 1);
|
||||
for (const entry of track.values()) {
|
||||
if (entry.chatSessionId === "chat-2") entry.cancel();
|
||||
}
|
||||
|
||||
// Now the channel "opens" — even though `sshClient.exec` would
|
||||
// hand us a working stream, we must short-circuit because the user
|
||||
// already cancelled.
|
||||
const fakeExecStream = {
|
||||
closed: false,
|
||||
close() { this.closed = true; },
|
||||
stderr: { on() {} },
|
||||
on() {},
|
||||
};
|
||||
execCallback(null, fakeExecStream);
|
||||
const result = await resultPromise;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, "Cancelled");
|
||||
assert.equal(fakeExecStream.closed, true, "should close the now-unwanted stream");
|
||||
assert.equal(track.size, 0, "pending marker should be removed after callback runs");
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { existsSync, statSync } = require("node:fs");
|
||||
const { existsSync, readFileSync, statSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// ── ANSI / URL regexes ──
|
||||
@@ -217,6 +217,53 @@ function prepareCommandForSpawn(command, args) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClaudeCodeExecutableForAcp(claudeExecutablePath, platform = process.platform) {
|
||||
const normalized = String(claudeExecutablePath || "").trim();
|
||||
if (!normalized) return null;
|
||||
if (platform !== "win32") return normalized;
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
if (ext && ext !== ".cmd" && ext !== ".bat") return normalized;
|
||||
|
||||
const baseDir = path.dirname(normalized);
|
||||
const packageCliPath = path.join(baseDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js");
|
||||
if (existsSync(packageCliPath)) {
|
||||
return packageCliPath;
|
||||
}
|
||||
|
||||
const shimCandidates = [normalized];
|
||||
if (!ext) {
|
||||
shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
|
||||
}
|
||||
|
||||
for (const shimPath of shimCandidates) {
|
||||
try {
|
||||
if (!existsSync(shimPath)) continue;
|
||||
const contents = readFileSync(shimPath, "utf8");
|
||||
if (!/node_modules[\\/]+@anthropic-ai[\\/]+claude-code[\\/]+cli\.js/i.test(contents)) {
|
||||
continue;
|
||||
}
|
||||
if (existsSync(packageCliPath)) {
|
||||
return packageCliPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the original executable path below.
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeClaudeCodeExecutableEnvForAcp(env, platform = process.platform) {
|
||||
if (!env?.CLAUDE_CODE_EXECUTABLE) return env;
|
||||
const resolved = resolveClaudeCodeExecutableForAcp(env.CLAUDE_CODE_EXECUTABLE, platform);
|
||||
if (!resolved || resolved === env.CLAUDE_CODE_EXECUTABLE) return env;
|
||||
return {
|
||||
...env,
|
||||
CLAUDE_CODE_EXECUTABLE: resolved,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCliFromPath(command, shellEnv) {
|
||||
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
|
||||
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
||||
@@ -433,6 +480,8 @@ module.exports = {
|
||||
quoteWindowsShellArg,
|
||||
buildWindowsShellCommandLine,
|
||||
prepareCommandForSpawn,
|
||||
resolveClaudeCodeExecutableForAcp,
|
||||
normalizeClaudeCodeExecutableEnvForAcp,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
|
||||
@@ -9,8 +9,12 @@ const {
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
resolveClaudeCodeExecutableForAcp,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
test("extracts a trailing PowerShell idle prompt", () => {
|
||||
assert.equal(
|
||||
@@ -103,6 +107,48 @@ test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command",
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveClaudeCodeExecutableForAcp maps Windows npm cmd shim to Claude Code cli.js", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-shim-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "claude.cmd");
|
||||
const scriptPath = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "cli.js");
|
||||
fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
|
||||
fs.writeFileSync(scriptPath, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveClaudeCodeExecutableForAcp(shimPath, "win32"), scriptPath);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveClaudeCodeExecutableForAcp leaves non-Windows Claude paths unchanged", () => {
|
||||
assert.equal(
|
||||
resolveClaudeCodeExecutableForAcp("/usr/local/bin/claude", "darwin"),
|
||||
"/usr/local/bin/claude",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveClaudeCodeExecutableForAcp keeps Windows cmd shim when Claude Code cli.js is missing", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-missing-cli-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "claude.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveClaudeCodeExecutableForAcp(shimPath, "win32"), shimPath);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const {
|
||||
stripAnsi,
|
||||
normalizeCliPathForPlatform,
|
||||
prepareCommandForSpawn,
|
||||
normalizeClaudeCodeExecutableEnvForAcp,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
@@ -37,6 +38,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 +565,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;
|
||||
}
|
||||
|
||||
@@ -2364,7 +2376,10 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
let agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
if (isClaudeAgent) {
|
||||
agentEnv = normalizeClaudeCodeExecutableEnvForAcp(agentEnv);
|
||||
}
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
@@ -2451,6 +2466,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 +2521,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 +2592,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([]) };
|
||||
@@ -2673,7 +2703,10 @@ function registerHandlers(ipcMain) {
|
||||
);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
let agentEnv = withCliDiscoveryEnv({ ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) });
|
||||
if (isClaudeAgent) {
|
||||
agentEnv = normalizeClaudeCodeExecutableEnvForAcp(agentEnv);
|
||||
}
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
@@ -2796,11 +2829,14 @@ function registerHandlers(ipcMain) {
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
env: (() => {
|
||||
const fallbackEnv = withCliDiscoveryEnv(
|
||||
let fallbackEnv = withCliDiscoveryEnv(
|
||||
isCodexAgent && apiKey
|
||||
? { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv), CODEX_API_KEY: apiKey }
|
||||
: { ...shellEnv, ...normalizeAgentEnv(requestedAgentEnv) },
|
||||
);
|
||||
if (isClaudeAgent) {
|
||||
fallbackEnv = normalizeClaudeCodeExecutableEnvForAcp(fallbackEnv);
|
||||
}
|
||||
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
|
||||
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
|
||||
}
|
||||
@@ -3009,11 +3045,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 +3083,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);
|
||||
|
||||
@@ -147,6 +147,10 @@ function loadBridgeWithMocks(options = {}) {
|
||||
typeof options.prepareCommandForSpawn === "function"
|
||||
? options.prepareCommandForSpawn(...args)
|
||||
: prepareCommandForSpawn(...args),
|
||||
normalizeClaudeCodeExecutableEnvForAcp: (env) =>
|
||||
typeof options.normalizeClaudeCodeExecutableEnvForAcp === "function"
|
||||
? options.normalizeClaudeCodeExecutableEnvForAcp(env)
|
||||
: env,
|
||||
isPlausibleCliVersionOutput: (value) =>
|
||||
typeof options.isPlausibleCliVersionOutput === "function"
|
||||
? options.isPlausibleCliVersionOutput(value)
|
||||
@@ -1149,6 +1153,74 @@ test("ACP stream passes the configured system Claude executable to claude-agent-
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP stream rewrites Windows Claude cmd shim env before creating claude-agent-acp", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-cmd-env-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const acpScriptPath = path.join(tempDir, "acp-index.js");
|
||||
fs.writeFileSync(acpScriptPath, "process.exit(0);\n", "utf8");
|
||||
|
||||
const cmdPath = "D:\\ProgramData\\develop-cache\\node-global\\claude.cmd";
|
||||
const cliPath = "D:\\ProgramData\\develop-cache\\node-global\\node_modules\\@anthropic-ai\\claude-code\\cli.js";
|
||||
const { bridge, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [acpScriptPath],
|
||||
}),
|
||||
normalizeClaudeCodeExecutableEnvForAcp: (env) => ({
|
||||
...env,
|
||||
CLAUDE_CODE_EXECUTABLE: env.CLAUDE_CODE_EXECUTABLE === cmdPath
|
||||
? cliPath
|
||||
: env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
createACPProvider: () => ({
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {},
|
||||
getSessionId() {
|
||||
return "claude-session";
|
||||
},
|
||||
cleanup() {},
|
||||
}),
|
||||
streamText: () => createEmptyStreamResult(),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
await streamHandler({ sender: { id: 1 } }, {
|
||||
requestId: "req-claude-cmd-env",
|
||||
chatSessionId: "chat-claude-cmd-env",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
prompt: "hello",
|
||||
historyMessages: [],
|
||||
toolIntegrationMode: "mcp",
|
||||
agentEnv: { CLAUDE_CODE_EXECUTABLE: cmdPath },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
providerCreationArgs[0].env.CLAUDE_CODE_EXECUTABLE,
|
||||
cliPath,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
@@ -25,6 +26,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
enableTcpNoDelay(socket);
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
@@ -48,11 +50,13 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
enableTcpNoDelay(socket);
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
enableTcpNoDelay(socket);
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
@@ -127,6 +131,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
enableTcpNoDelay(socket);
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
|
||||
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
@@ -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,
|
||||
@@ -39,6 +40,7 @@ const {
|
||||
buildAlgorithms,
|
||||
_resetAlgorithmSupportCacheForTests,
|
||||
} = require("./sshAlgorithms.cjs");
|
||||
const { enableSshNoDelay, enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -608,6 +610,8 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
),
|
||||
}));
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.once('connect', () => enableSshNoDelay(conn));
|
||||
if (connOpts.sock) enableTcpNoDelay(connOpts.sock);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -1176,6 +1180,9 @@ async function startSSHSession(event, options) {
|
||||
let settled = false;
|
||||
let detachX11Forwarding = null;
|
||||
|
||||
conn.once("connect", () => enableSshNoDelay(conn));
|
||||
if (connectOpts.sock) enableTcpNoDelay(connectOpts.sock);
|
||||
|
||||
conn.once("handshake", () => {
|
||||
console.log(`${logPrefix} ${options.hostname} handshake complete`);
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
|
||||
@@ -1287,35 +1294,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 +1378,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) {
|
||||
|
||||
22
electron/bridges/tcpNoDelay.cjs
Normal file
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
function enableTcpNoDelay(socket) {
|
||||
try {
|
||||
socket?.setNoDelay?.(true);
|
||||
} catch {
|
||||
// Best-effort latency hint; the connection can continue without it.
|
||||
}
|
||||
}
|
||||
|
||||
function enableSshNoDelay(conn) {
|
||||
try {
|
||||
conn?.setNoDelay?.(true);
|
||||
} catch {
|
||||
// Best-effort latency hint; the SSH session can continue without it.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enableSshNoDelay,
|
||||
enableTcpNoDelay,
|
||||
};
|
||||
@@ -25,6 +25,8 @@ 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 { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -80,51 +82,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 +411,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 });
|
||||
});
|
||||
@@ -526,6 +483,7 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new net.Socket();
|
||||
enableTcpNoDelay(socket);
|
||||
let connected = false;
|
||||
// Token for the log stream we open on this connection. Captured here so
|
||||
// the close/error handlers below can pass it back to stopStream and
|
||||
@@ -615,6 +573,7 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
socket.on('connect', () => {
|
||||
connected = true;
|
||||
enableTcpNoDelay(socket);
|
||||
clearTimeout(connectTimeout);
|
||||
console.log(`[Telnet] Connected to ${hostname}:${port}`);
|
||||
|
||||
@@ -662,7 +621,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 +1148,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 +1552,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 +1691,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 +1877,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
@@ -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?(
|
||||
|
||||
@@ -25,6 +25,14 @@ export interface NetcattyBridge {
|
||||
exitCode?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
/**
|
||||
* Cancel any in-flight Catty Agent command execution scoped to the
|
||||
* given chat session. Idempotent — safe to call when nothing is
|
||||
* running. Used by tools to re-issue cancel during the IPC transit
|
||||
* window if the user clicks Stop after we've already dispatched
|
||||
* `aiExec` but before the main process has registered it.
|
||||
*/
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
// Workspace context provided to the executor
|
||||
|
||||
59
infrastructure/ai/modelDiscoveryHeaders.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "./modelDiscoveryHeaders";
|
||||
|
||||
test("buildModelDiscoveryHeaders uses x-api-key+anthropic-version for the anthropic family", () => {
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("anthropic", "sk-test"), {
|
||||
"x-api-key": "sk-test",
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildModelDiscoveryHeaders uses Bearer auth for the openai-compatible family", () => {
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("openai", "sk-test"), {
|
||||
Authorization: "Bearer sk-test",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildModelDiscoveryHeaders uses x-goog-api-key for the google family", () => {
|
||||
// Google Generative AI rejects Bearer auth — discovery has to match
|
||||
// the createGoogleGenerativeAI runtime client, which uses x-goog-api-key.
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("google", "AIza-test"), {
|
||||
"x-goog-api-key": "AIza-test",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildModelDiscoveryHeaders returns no headers when the api key is missing", () => {
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("anthropic", undefined), {});
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("openai", ""), {});
|
||||
});
|
||||
|
||||
test("buildModelDiscoveryHeaders honors the style override on an anthropic providerId pointing at an OpenAI-compatible backend", () => {
|
||||
// Regression: PR #1105 lets users pick `style` independently from
|
||||
// `providerId`. Without this fix the discovery call still sent
|
||||
// `x-api-key` because the old code switched on `providerId === "anthropic"`.
|
||||
assert.deepEqual(buildModelDiscoveryHeaders("openai", "sk-test"), {
|
||||
Authorization: "Bearer sk-test",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveModelsDiscoveryEndpoint follows the resolved style by default", () => {
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("openai"), "/models");
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("anthropic"), "/v1/models");
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("google"), undefined);
|
||||
});
|
||||
|
||||
test("resolveModelsDiscoveryEndpoint overrides the preset path when style flips", () => {
|
||||
// Anthropic providerId preset would otherwise pin /v1/models, but the user
|
||||
// switched style to openai — pick /models instead so the path matches the
|
||||
// protocol family the headers already speak.
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("openai", "/v1/models"), "/models");
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("anthropic", "/models"), "/v1/models");
|
||||
});
|
||||
|
||||
test("resolveModelsDiscoveryEndpoint falls back to the preset path only when the style has no convention", () => {
|
||||
// google has no STYLE_DEFAULT — preserve whatever the caller passed.
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("google", "/custom/list"), "/custom/list");
|
||||
assert.equal(resolveModelsDiscoveryEndpoint("google", undefined), undefined);
|
||||
});
|
||||
58
infrastructure/ai/modelDiscoveryHeaders.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ProviderStyle } from "./types";
|
||||
|
||||
/**
|
||||
* Conventional `/models`-listing path for each wire-protocol family. These
|
||||
* are the same paths the official Anthropic/OpenAI/Google clients use,
|
||||
* so they line up with what compliant Anthropic-compat or OpenAI-compat
|
||||
* third parties (DeepSeek, Moonshot, Qwen, Ollama, OpenRouter, ...)
|
||||
* expose. Google is `undefined` because Generative AI's discovery isn't
|
||||
* a standard REST listing.
|
||||
*/
|
||||
export const STYLE_DEFAULT_MODELS_ENDPOINT: Record<ProviderStyle, string | undefined> = {
|
||||
openai: "/models",
|
||||
anthropic: "/v1/models",
|
||||
google: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the `/models` discovery path for a provider config. The resolved
|
||||
* `style` wins — keeping it aligned with {@link buildModelDiscoveryHeaders}
|
||||
* — falling back to the providerId-derived `presetEndpoint` only when the
|
||||
* style has no convention of its own.
|
||||
*/
|
||||
export function resolveModelsDiscoveryEndpoint(
|
||||
style: ProviderStyle,
|
||||
presetEndpoint?: string,
|
||||
): string | undefined {
|
||||
return STYLE_DEFAULT_MODELS_ENDPOINT[style] ?? presetEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick auth headers for a provider's `/models` discovery endpoint.
|
||||
*
|
||||
* Each wire-protocol family uses its own auth dialect:
|
||||
* - `anthropic`: `x-api-key` + `anthropic-version`
|
||||
* - `google`: `x-goog-api-key` (Google Generative AI rejects Bearer)
|
||||
* - `openai`: `Authorization: Bearer …` (also the OpenAI-compat default)
|
||||
*
|
||||
* Returning an empty object when the key is missing lets the caller still
|
||||
* issue an unauthenticated probe (e.g. against local Ollama).
|
||||
*/
|
||||
export function buildModelDiscoveryHeaders(
|
||||
style: ProviderStyle,
|
||||
apiKey: string | undefined,
|
||||
): Record<string, string> {
|
||||
if (!apiKey) return {};
|
||||
switch (style) {
|
||||
case "anthropic":
|
||||
return {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
};
|
||||
case "google":
|
||||
return { "x-goog-api-key": apiKey };
|
||||
case "openai":
|
||||
default:
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
}
|
||||
23
infrastructure/ai/providerStyle.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveProviderStyle } from "./types";
|
||||
|
||||
test("resolveProviderStyle prefers an explicit style override", () => {
|
||||
assert.equal(resolveProviderStyle({ providerId: "custom", style: "anthropic" }), "anthropic");
|
||||
assert.equal(resolveProviderStyle({ providerId: "openai", style: "google" }), "google");
|
||||
});
|
||||
|
||||
test("resolveProviderStyle falls back to providerId for anthropic", () => {
|
||||
assert.equal(resolveProviderStyle({ providerId: "anthropic" }), "anthropic");
|
||||
});
|
||||
|
||||
test("resolveProviderStyle falls back to providerId for google", () => {
|
||||
assert.equal(resolveProviderStyle({ providerId: "google" }), "google");
|
||||
});
|
||||
|
||||
test("resolveProviderStyle treats every other providerId as the OpenAI-compatible family", () => {
|
||||
for (const providerId of ["openai", "ollama", "openrouter", "custom"] as const) {
|
||||
assert.equal(resolveProviderStyle({ providerId }), "openai", `expected openai for ${providerId}`);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import type { ProviderConfig, ProviderStyle } from '../types';
|
||||
import { resolveProviderStyle } from '../types';
|
||||
import {
|
||||
applyOpenAIChatContinuationToBody,
|
||||
extractProviderContinuationFromRawChunk,
|
||||
@@ -405,6 +406,35 @@ export function createBridgeFetchForSDK(
|
||||
* process replaces the placeholder with the real decrypted key before
|
||||
* making the HTTP request.
|
||||
*/
|
||||
/**
|
||||
* Apply per-vendor URL and apiKey quirks on top of the style-based
|
||||
* wire-protocol routing. Exported so it can be unit-tested without spinning
|
||||
* up the Vercel AI SDK clients.
|
||||
*
|
||||
* The URL fallback fires regardless of style — the user picked this
|
||||
* providerId for a reason, even if they overrode the wire format. The
|
||||
* ollama `'ollama'` throwaway apiKey is style-specific: it's only meaningful
|
||||
* to the OpenAI-compat client, since Anthropic/Google clients need a real
|
||||
* key on their own URL.
|
||||
*/
|
||||
export function resolveProviderEndpoint(
|
||||
config: ProviderConfig,
|
||||
style: ProviderStyle,
|
||||
safeApiKey: string | undefined,
|
||||
): { baseURL: string | undefined; apiKey: string | undefined } {
|
||||
let baseURL = config.baseURL;
|
||||
let apiKey = safeApiKey;
|
||||
if (config.providerId === 'ollama') {
|
||||
baseURL = baseURL || 'http://localhost:11434/v1';
|
||||
if (style === 'openai') {
|
||||
apiKey = 'ollama';
|
||||
}
|
||||
} else if (config.providerId === 'openrouter') {
|
||||
baseURL = baseURL || 'https://openrouter.ai/api/v1';
|
||||
}
|
||||
return { baseURL, apiKey };
|
||||
}
|
||||
|
||||
export function createModelFromConfig(
|
||||
config: ProviderConfig,
|
||||
requestContext?: ProviderRequestContext,
|
||||
@@ -413,57 +443,35 @@ export function createModelFromConfig(
|
||||
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
|
||||
const customFetch = createBridgeFetchForSDK(config.id, requestContext);
|
||||
const modelId = config.defaultModel || '';
|
||||
const style = resolveProviderStyle(config);
|
||||
const { baseURL, apiKey } = resolveProviderEndpoint(config, style, safeApiKey);
|
||||
|
||||
switch (config.providerId) {
|
||||
switch (style) {
|
||||
case 'openai':
|
||||
// Use .chat() to force Chat Completions API (not Responses API)
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'anthropic':
|
||||
return createAnthropic({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
case 'google':
|
||||
return createGoogleGenerativeAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
case 'ollama':
|
||||
// Ollama uses OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: 'ollama',
|
||||
baseURL: config.baseURL || 'http://localhost:11434/v1',
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'openrouter':
|
||||
// OpenRouter uses OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL || 'https://openrouter.ai/api/v1',
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'custom':
|
||||
// Custom providers use OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = config.providerId;
|
||||
throw new Error(`Unsupported provider: ${_exhaustive}`);
|
||||
const _exhaustive: never = style;
|
||||
throw new Error(`Unsupported provider style: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
import { requestApproval } from '../shared/approvalGate';
|
||||
import { reserveSessionSlot } from '../shared/sessionExecutionQueue';
|
||||
|
||||
/** Unwrap a shared ToolExecResult into the shape expected by Vercel AI SDK tool results. */
|
||||
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
@@ -49,15 +50,67 @@ export function createCattyTools(
|
||||
command: z.string().describe('The shell command to execute in the target session.'),
|
||||
}),
|
||||
// No needsApproval — approval is handled inside execute via the approval gate.
|
||||
execute: async ({ sessionId, command }, { toolCallId }) => {
|
||||
// In confirm mode, await user approval before executing
|
||||
if (permissionMode === 'confirm') {
|
||||
const approved = await requestApproval(toolCallId, 'terminal_execute', { sessionId, command }, chatSessionId);
|
||||
if (!approved) {
|
||||
return { error: 'User denied command execution.' };
|
||||
execute: async ({ sessionId, command }, { toolCallId, abortSignal }) => {
|
||||
// Snap our place in the per-session execution queue *first*,
|
||||
// synchronously, so the eventual command-run order matches the
|
||||
// LLM's tool_use emission order regardless of how long each
|
||||
// call's approval prompt takes to settle. Vercel AI SDK
|
||||
// dispatches every tool_use block in a turn through
|
||||
// `Promise.all(...)`, so the three executes for "A then B then
|
||||
// C" all start at the same instant; if we deferred slot
|
||||
// reservation until after approval, B's approval could land
|
||||
// first and run B before A. Reserving up front fixes that.
|
||||
//
|
||||
// The bridge-side mutex (mcpServerBridge.reserveSessionExecution)
|
||||
// stays as defense-in-depth for non-LLM IPC paths
|
||||
// (terminal_start, MCP, etc.) — this queue just keeps the
|
||||
// renderer-side LLM path from racing into it.
|
||||
const queueKey = `${chatSessionId ?? 'global'}:${sessionId}`;
|
||||
const slot = reserveSessionSlot(queueKey);
|
||||
try {
|
||||
// In confirm mode, await user approval. Approvals run *while*
|
||||
// the slot is held but before the serialized work, so multiple
|
||||
// parallel tool_use blocks each surface their own approval
|
||||
// card immediately and the user can approve/deny in any
|
||||
// order — the queue still drains in reservation order.
|
||||
if (permissionMode === 'confirm') {
|
||||
const approved = await requestApproval(toolCallId, 'terminal_execute', { sessionId, command }, chatSessionId);
|
||||
if (!approved) {
|
||||
return { error: 'User denied command execution.' };
|
||||
}
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
return { error: 'Command cancelled before it could start.' };
|
||||
}
|
||||
await slot.ready;
|
||||
if (abortSignal?.aborted) {
|
||||
return { error: 'Command cancelled before it could start.' };
|
||||
}
|
||||
// There's a tiny race between this check and the main-process
|
||||
// `mcpServerBridge.reserveSessionExecution` registering the new
|
||||
// exec into the cancellation tracker: `handleStop` issues a
|
||||
// cancel IPC and the user's abort signal fires, but if our
|
||||
// `aiExec` IPC was already in transit, the cancel may run
|
||||
// before the exec has registered — and find nothing to
|
||||
// cancel. Re-issue the cancel from the abort listener so a
|
||||
// duplicate `aiCattyCancelExec` lands once the registration
|
||||
// is complete. The cancel is idempotent (it only acts on
|
||||
// entries it finds in `activePtyExecs`), so issuing twice is
|
||||
// harmless.
|
||||
const cancelOnAbort = () => {
|
||||
if (chatSessionId) {
|
||||
void bridge.aiCattyCancelExec?.(chatSessionId);
|
||||
}
|
||||
};
|
||||
abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
|
||||
try {
|
||||
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
|
||||
} finally {
|
||||
abortSignal?.removeEventListener('abort', cancelOnAbort);
|
||||
}
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
100
infrastructure/ai/sdkProviders.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
|
||||
import type { ProviderConfig } from "./types";
|
||||
|
||||
function makeConfig(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: "p",
|
||||
providerId: "custom",
|
||||
name: "Test",
|
||||
enabled: true,
|
||||
defaultModel: "m",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("createModelFromConfig routes by explicit style: anthropic on top of custom providerId", () => {
|
||||
const model = createModelFromConfig(makeConfig({ style: "anthropic", defaultModel: "claude-3-5-sonnet" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^anthropic/);
|
||||
assert.equal((model as { modelId?: string }).modelId, "claude-3-5-sonnet");
|
||||
});
|
||||
|
||||
test("createModelFromConfig routes by explicit style: google on top of custom providerId", () => {
|
||||
const model = createModelFromConfig(makeConfig({ style: "google", defaultModel: "gemini-2.0-flash" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^google/);
|
||||
});
|
||||
|
||||
test("createModelFromConfig defaults legacy custom providerId to the OpenAI-compatible client", () => {
|
||||
const model = createModelFromConfig(makeConfig({ providerId: "custom", defaultModel: "gpt-4o" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^openai/);
|
||||
});
|
||||
|
||||
test("createModelFromConfig keeps the Anthropic providerId fallback when style is unset", () => {
|
||||
const model = createModelFromConfig(makeConfig({ providerId: "anthropic", defaultModel: "claude" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^anthropic/);
|
||||
});
|
||||
|
||||
test("createModelFromConfig keeps the Google providerId fallback when style is unset", () => {
|
||||
const model = createModelFromConfig(makeConfig({ providerId: "google", defaultModel: "gemini" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^google/);
|
||||
});
|
||||
|
||||
test("createModelFromConfig keeps ollama's baseURL fallback and disposable apiKey", () => {
|
||||
const model = createModelFromConfig(makeConfig({ providerId: "ollama", defaultModel: "llama3" }));
|
||||
assert.match(String((model as { provider?: string }).provider ?? ""), /^openai/);
|
||||
// Ollama leaves URL building to the SDK, but we can at least confirm it's still treated as OpenAI-style.
|
||||
});
|
||||
|
||||
test("resolveProviderEndpoint applies the openrouter URL fallback for every style override", () => {
|
||||
// Regression for codex feedback on #1105: gating the fallback on
|
||||
// style === 'openai' silently misrouted traffic away from openrouter.ai
|
||||
// when users overrode the wire format.
|
||||
for (const style of ["openai", "anthropic", "google"] as const) {
|
||||
const result = resolveProviderEndpoint(
|
||||
{ id: "p", providerId: "openrouter", name: "OR", enabled: true },
|
||||
style,
|
||||
"sk-test",
|
||||
);
|
||||
assert.equal(result.baseURL, "https://openrouter.ai/api/v1", `style=${style} should still hit openrouter.ai`);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveProviderEndpoint keeps an explicit openrouter baseURL untouched", () => {
|
||||
const result = resolveProviderEndpoint(
|
||||
{ id: "p", providerId: "openrouter", name: "OR", enabled: true, baseURL: "https://proxy.example/v1" },
|
||||
"anthropic",
|
||||
"sk-test",
|
||||
);
|
||||
assert.equal(result.baseURL, "https://proxy.example/v1");
|
||||
});
|
||||
|
||||
test("resolveProviderEndpoint applies the ollama URL fallback for every style override", () => {
|
||||
for (const style of ["openai", "anthropic", "google"] as const) {
|
||||
const result = resolveProviderEndpoint(
|
||||
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||||
style,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(result.baseURL, "http://localhost:11434/v1", `style=${style} should still hit localhost ollama`);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveProviderEndpoint only swaps in the literal 'ollama' apiKey on the OpenAI-compat client", () => {
|
||||
const openai = resolveProviderEndpoint(
|
||||
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||||
"openai",
|
||||
"PLACEHOLDER",
|
||||
);
|
||||
assert.equal(openai.apiKey, "ollama");
|
||||
|
||||
// For Anthropic/Google styles the user supplied a real key; preserve it
|
||||
// verbatim so the SDK forwards the right header instead of "ollama".
|
||||
const anthropic = resolveProviderEndpoint(
|
||||
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||||
"anthropic",
|
||||
"PLACEHOLDER",
|
||||
);
|
||||
assert.equal(anthropic.apiKey, "PLACEHOLDER");
|
||||
});
|
||||
187
infrastructure/ai/sessionExecutionQueue.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
chainBySessionKey,
|
||||
getSessionExecutionQueueSizeForTests,
|
||||
reserveSessionSlot,
|
||||
resetSessionExecutionQueueForTests,
|
||||
} from "./shared/sessionExecutionQueue";
|
||||
|
||||
function defer<T = void>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (err: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Drain pending microtasks and immediates so cleanup `.finally`s have run.
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
|
||||
test("chainBySessionKey serializes calls sharing the same key in dispatch order", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
const gateC = defer();
|
||||
|
||||
const a = chainBySessionKey("session-1", async () => {
|
||||
events.push("a:start");
|
||||
await gateA.promise;
|
||||
events.push("a:end");
|
||||
return "a";
|
||||
});
|
||||
const b = chainBySessionKey("session-1", async () => {
|
||||
events.push("b:start");
|
||||
await gateB.promise;
|
||||
events.push("b:end");
|
||||
return "b";
|
||||
});
|
||||
const c = chainBySessionKey("session-1", async () => {
|
||||
events.push("c:start");
|
||||
await gateC.promise;
|
||||
events.push("c:end");
|
||||
return "c";
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start"]);
|
||||
|
||||
gateA.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start", "a:end", "b:start"]);
|
||||
|
||||
gateB.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start", "a:end", "b:start", "b:end", "c:start"]);
|
||||
|
||||
gateC.resolve();
|
||||
assert.equal(await a, "a");
|
||||
assert.equal(await b, "b");
|
||||
assert.equal(await c, "c");
|
||||
});
|
||||
|
||||
test("chainBySessionKey does not let one task's rejection poison the next", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const a = chainBySessionKey("session-2", async () => {
|
||||
throw new Error("a boom");
|
||||
});
|
||||
const b = chainBySessionKey("session-2", async () => "b ok");
|
||||
|
||||
await assert.rejects(a, /a boom/);
|
||||
assert.equal(await b, "b ok");
|
||||
});
|
||||
|
||||
test("chainBySessionKey isolates work across keys (different sessions run in parallel)", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
|
||||
const a = chainBySessionKey("session-a", async () => {
|
||||
await gateA.promise;
|
||||
return "a";
|
||||
});
|
||||
const b = chainBySessionKey("session-b", async () => {
|
||||
await gateB.promise;
|
||||
return "b";
|
||||
});
|
||||
|
||||
// Resolve B first; it should finish even though A is still blocked.
|
||||
gateB.resolve();
|
||||
assert.equal(await b, "b");
|
||||
gateA.resolve();
|
||||
assert.equal(await a, "a");
|
||||
});
|
||||
|
||||
test("queue Map drops the entry once the last task drains", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
resetSessionExecutionQueueForTests();
|
||||
assert.equal(getSessionExecutionQueueSizeForTests(), 0);
|
||||
await chainBySessionKey("session-cleanup", async () => "done");
|
||||
await flushMicrotasks();
|
||||
// Without the cleanup `finally`, the resolved tail would stay parked
|
||||
// in the map; this is the regression guard that the previous version
|
||||
// of this test was missing.
|
||||
assert.equal(getSessionExecutionQueueSizeForTests(), 0);
|
||||
});
|
||||
|
||||
test("reserveSessionSlot fixes order at reservation time regardless of pre-work duration", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
|
||||
// Simulate three tool_use calls firing in parallel where each has
|
||||
// some pre-work (e.g. an approval prompt) of varying length. The
|
||||
// serialized work order must still match the slot-reservation order.
|
||||
async function run(name: string, prework: Promise<void>) {
|
||||
const slot = reserveSessionSlot("session-order");
|
||||
try {
|
||||
events.push(`${name}:reserved`);
|
||||
await prework;
|
||||
events.push(`${name}:prework-done`);
|
||||
await slot.ready;
|
||||
events.push(`${name}:serial-start`);
|
||||
// Trivial serial work, just so we can observe the start order.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
events.push(`${name}:serial-end`);
|
||||
return name;
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
const gateC = defer();
|
||||
const a = run("A", gateA.promise);
|
||||
const b = run("B", gateB.promise);
|
||||
const c = run("C", gateC.promise);
|
||||
|
||||
// Approvals land in reverse order — C first, then B, then A.
|
||||
await flushMicrotasks();
|
||||
gateC.resolve();
|
||||
await flushMicrotasks();
|
||||
gateB.resolve();
|
||||
await flushMicrotasks();
|
||||
gateA.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(await Promise.all([a, b, c]), ["A", "B", "C"]);
|
||||
|
||||
const serialStarts = events.filter((e) => e.endsWith(":serial-start"));
|
||||
assert.deepEqual(
|
||||
serialStarts,
|
||||
["A:serial-start", "B:serial-start", "C:serial-start"],
|
||||
`serial work ran in reservation order, not approval order; got: ${events.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reserveSessionSlot lets later slots proceed once an early slot releases without serialized work", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
|
||||
async function maybeRun(name: string, run: boolean) {
|
||||
const slot = reserveSessionSlot("session-skip");
|
||||
try {
|
||||
events.push(`${name}:reserved`);
|
||||
if (!run) return `${name}:skipped`;
|
||||
await slot.ready;
|
||||
events.push(`${name}:ran`);
|
||||
return `${name}:ran`;
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Slot A reserves but skips serial work (mimics user denying approval
|
||||
// or aborting). Slot B should still proceed.
|
||||
const a = maybeRun("A", false);
|
||||
const b = maybeRun("B", true);
|
||||
assert.equal(await a, "A:skipped");
|
||||
assert.equal(await b, "B:ran");
|
||||
});
|
||||
127
infrastructure/ai/shared/sessionExecutionQueue.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Per-session execution queue for tool calls that target the same terminal
|
||||
* session.
|
||||
*
|
||||
* Background — issue #1101 problem 3:
|
||||
*
|
||||
* Vercel AI SDK dispatches every tool_use block emitted in one assistant
|
||||
* turn through `Promise.all(toolCalls.map(execute))`, so an LLM that asks
|
||||
* for three commands "at once" sends three simultaneous `bridge.aiExec()`
|
||||
* calls at the underlying PTY. The main-process session mutex
|
||||
* (`mcpServerBridge.reserveSessionExecution`) only lets one through and
|
||||
* rejects the rest with `{ ok: false, error: "Session already has another
|
||||
* command in progress..." }`. The LLM then sees two synthetic errors plus
|
||||
* one real result for a turn it expected to be all-or-nothing — and the
|
||||
* Anthropic API has occasionally rejected the resulting trace with a
|
||||
* `tool_use ids were found without tool_result blocks` 400.
|
||||
*
|
||||
* The cleanest fix is to never let those calls race in the first place:
|
||||
* serialize at the renderer-side tool execute boundary so the bridge sees
|
||||
* one command per session at a time. The bridge mutex stays as
|
||||
* defense-in-depth for non-LLM IPC paths (terminal_start, MCP, etc.).
|
||||
*
|
||||
* The queue exposes both a high-level `chainBySessionKey(key, task)` for
|
||||
* simple "run this when it's our turn" callers, and a lower-level
|
||||
* `reserveSessionSlot(key)` for callers that need to do non-blocking work
|
||||
* (e.g. await an approval prompt) *while* their queue slot is held — so
|
||||
* the queue order matches the LLM's emission order independent of when
|
||||
* each call's approval lands.
|
||||
*/
|
||||
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* A reserved slot in a session's execution queue. The slot is added to
|
||||
* the queue tail synchronously when {@link reserveSessionSlot} is called,
|
||||
* so call order is fixed at reservation time — regardless of how long
|
||||
* each caller spends on pre-work (approval prompts, abort checks, etc.)
|
||||
* before they actually start.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. `reserveSessionSlot(key)` — synchronously snaps a place in line.
|
||||
* 2. caller does whatever pre-work they want, in parallel with siblings.
|
||||
* 3. `await slot.ready` — blocks until the previous slot has released.
|
||||
* 4. caller does the serialized work.
|
||||
* 5. `slot.release()` — frees the next slot. Idempotent.
|
||||
*
|
||||
* The slot **must** be released exactly once (typically from a `finally`)
|
||||
* even if the caller decides to skip the serialized work — otherwise
|
||||
* subsequent slots queued behind it never start.
|
||||
*/
|
||||
export interface SessionExecutionSlot {
|
||||
/** Resolves when this slot is at the head of its queue. */
|
||||
readonly ready: Promise<void>;
|
||||
/** Releases this slot. Safe to call multiple times. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export function reserveSessionSlot(key: string): SessionExecutionSlot {
|
||||
const prev = queues.get(key) ?? Promise.resolve();
|
||||
|
||||
let resolveDone!: () => void;
|
||||
const done = new Promise<void>((r) => {
|
||||
resolveDone = r;
|
||||
});
|
||||
|
||||
// The new tail of this key's queue: previous tail → our `done`.
|
||||
// Wrap in a non-rejecting chain so a thrown task never poisons later
|
||||
// callers waiting on this tail.
|
||||
const tail: Promise<unknown> = prev.then(() => done).catch(() => undefined);
|
||||
queues.set(key, tail);
|
||||
|
||||
// Best-effort cleanup once we're the last in line — keeps the map
|
||||
// from growing without bound across many short-lived sessions. A
|
||||
// later caller that arrived between `queues.set` and this finally
|
||||
// will already have replaced the tail; we only clear when we're
|
||||
// still it.
|
||||
void tail.finally(() => {
|
||||
if (queues.get(key) === tail) {
|
||||
queues.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
let released = false;
|
||||
return {
|
||||
ready: prev.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
release(): void {
|
||||
if (released) return;
|
||||
released = true;
|
||||
resolveDone();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` after every previously-reserved slot with the same `key`
|
||||
* has released. Returns the task's resolved value (or rejects if the
|
||||
* task throws). A failure in one task does not poison the queue head
|
||||
* for subsequent callers — the chain only waits on settlement, not
|
||||
* success.
|
||||
*
|
||||
* For callers that need to interleave pre-work with the queue wait
|
||||
* (e.g. approval prompts that should run in parallel even though the
|
||||
* actual command must run serially), use {@link reserveSessionSlot}
|
||||
* directly.
|
||||
*/
|
||||
export async function chainBySessionKey<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const slot = reserveSessionSlot(key);
|
||||
try {
|
||||
await slot.ready;
|
||||
return await task();
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: inspect the live queue. */
|
||||
export function getSessionExecutionQueueSizeForTests(): number {
|
||||
return queues.size;
|
||||
}
|
||||
|
||||
/** Test-only: drop all queued work. */
|
||||
export function resetSessionExecutionQueueForTests(): void {
|
||||
queues.clear();
|
||||
}
|
||||
50
infrastructure/ai/shared/streamStateErrors.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Helpers for detecting Vercel AI SDK internal stream-state errors.
|
||||
*
|
||||
* Background — issue #1101 follow-up:
|
||||
*
|
||||
* When a third-party Anthropic-compat backend (DeepSeek's
|
||||
* `deepseek-v4-flash` is the canonical offender) streams thinking
|
||||
* deltas without first emitting a `reasoning-start` content-block
|
||||
* signal, the Vercel AI SDK's reasoning state machine has nothing
|
||||
* registered for the incoming `part.id` and enqueues an
|
||||
* `error` chunk on `fullStream` with the text
|
||||
* `reasoning part <id> not found` — once per orphan delta. The
|
||||
* analogous error exists for text parts.
|
||||
*
|
||||
* These chunks are *internal SDK bookkeeping noise*, not user-facing
|
||||
* errors. Worse, treating them as real errors (adding a placeholder
|
||||
* assistant message for each) breaks tool_use/tool_result contiguity
|
||||
* on the next turn: the Anthropic message grouper splits the
|
||||
* tool-result `role: 'tool'` messages from their parent tool_use
|
||||
* `role: 'assistant'`, and the backend responds with
|
||||
* `400 messages.N: tool_use ids were found without tool_result blocks
|
||||
* immediately after`.
|
||||
*
|
||||
* Filtering these specific errors at the chunk-handler boundary
|
||||
* stops the cascade: the orphan deltas are dropped silently (the SDK
|
||||
* continues processing other chunks), no fake assistant messages
|
||||
* land in history, and the next turn's request stays well-formed.
|
||||
*/
|
||||
|
||||
const STATE_ERROR_PATTERN = /^(?:reasoning|text)\s+part\s+\S+\s+not\s+found$/i;
|
||||
|
||||
/**
|
||||
* Return true if `error` is one of the SDK's internal stream-state
|
||||
* tracking errors (e.g. an out-of-order reasoning delta). Accepts
|
||||
* the loose `unknown` shape that comes off the chunk so callers
|
||||
* don't need to narrow upstream.
|
||||
*/
|
||||
export function isSdkStreamStateError(error: unknown): boolean {
|
||||
if (typeof error === 'string') {
|
||||
return STATE_ERROR_PATTERN.test(error.trim());
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return STATE_ERROR_PATTERN.test(error.message.trim());
|
||||
}
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const msg = (error as { message?: unknown }).message;
|
||||
if (typeof msg === 'string') return STATE_ERROR_PATTERN.test(msg.trim());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
39
infrastructure/ai/streamStateErrors.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isSdkStreamStateError } from "./shared/streamStateErrors";
|
||||
|
||||
test("isSdkStreamStateError matches the reasoning-part orphan-delta error", () => {
|
||||
assert.equal(isSdkStreamStateError("reasoning part 0 not found"), true);
|
||||
assert.equal(isSdkStreamStateError("reasoning part abc-123 not found"), true);
|
||||
assert.equal(isSdkStreamStateError("Reasoning Part 0 Not Found"), true);
|
||||
assert.equal(isSdkStreamStateError(" reasoning part 7 not found "), true);
|
||||
});
|
||||
|
||||
test("isSdkStreamStateError matches the text-part orphan-delta error", () => {
|
||||
assert.equal(isSdkStreamStateError("text part 0 not found"), true);
|
||||
assert.equal(isSdkStreamStateError("text part X not found"), true);
|
||||
});
|
||||
|
||||
test("isSdkStreamStateError accepts the error wrapped in an Error or `{ message }`", () => {
|
||||
assert.equal(isSdkStreamStateError(new Error("reasoning part 0 not found")), true);
|
||||
assert.equal(isSdkStreamStateError({ message: "reasoning part 0 not found" }), true);
|
||||
});
|
||||
|
||||
test("isSdkStreamStateError ignores unrelated error strings", () => {
|
||||
assert.equal(isSdkStreamStateError("Network error"), false);
|
||||
assert.equal(
|
||||
isSdkStreamStateError("400 messages.13: tool_use ids were found without tool_result blocks"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSdkStreamStateError("reasoning part not found"), false, "missing the id segment shouldn't match");
|
||||
assert.equal(isSdkStreamStateError("a reasoning part 0 not found"), false, "leading text shouldn't match");
|
||||
});
|
||||
|
||||
test("isSdkStreamStateError handles non-error inputs without throwing", () => {
|
||||
assert.equal(isSdkStreamStateError(undefined), false);
|
||||
assert.equal(isSdkStreamStateError(null), false);
|
||||
assert.equal(isSdkStreamStateError(42), false);
|
||||
assert.equal(isSdkStreamStateError({}), false);
|
||||
assert.equal(isSdkStreamStateError([]), false);
|
||||
});
|
||||
@@ -4,6 +4,14 @@ import type { ProviderContinuation } from './providerContinuation';
|
||||
|
||||
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
|
||||
|
||||
/**
|
||||
* Wire-protocol family for a provider. Three are supported because every
|
||||
* Anthropic/OpenAI-compatible third party reduces to one of these.
|
||||
* `providerId` stays as the routing/display identity; `style` decides
|
||||
* which Vercel AI SDK client builds the request.
|
||||
*/
|
||||
export type ProviderStyle = 'openai' | 'anthropic' | 'google';
|
||||
|
||||
export interface ProviderAdvancedParams {
|
||||
maxTokens?: number;
|
||||
temperature?: number; // 0–2
|
||||
@@ -16,6 +24,12 @@ export interface ProviderConfig {
|
||||
id: string;
|
||||
providerId: AIProviderId;
|
||||
name: string;
|
||||
/** Override the wire-protocol family; defaults from `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
/** Built-in icon key (slug under public/ai/providers/), independent of providerId. */
|
||||
iconId?: string;
|
||||
/** User-supplied icon as a data URL (compressed to 64x64 webp at write time). Wins over iconId. */
|
||||
iconDataUrl?: string;
|
||||
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
|
||||
baseURL?: string; // custom endpoint URL
|
||||
defaultModel?: string;
|
||||
@@ -25,6 +39,19 @@ export interface ProviderConfig {
|
||||
advancedParams?: ProviderAdvancedParams;
|
||||
}
|
||||
|
||||
/** Pick the protocol family for a provider config, falling back from providerId when style is unset. */
|
||||
export function resolveProviderStyle(config: Pick<ProviderConfig, 'providerId' | 'style'>): ProviderStyle {
|
||||
if (config.style) return config.style;
|
||||
switch (config.providerId) {
|
||||
case 'anthropic':
|
||||
return 'anthropic';
|
||||
case 'google':
|
||||
return 'google';
|
||||
default:
|
||||
return 'openai';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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';
|
||||
@@ -129,6 +131,7 @@ export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
|
||||
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
|
||||
export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_PROVIDER_MAP = 'netcatty_ai_agent_provider_map_v1';
|
||||
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
|
||||
// SFTP Transfer Concurrency
|
||||
|
||||
53
public/ai/providers/NOTICE.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Provider icons
|
||||
|
||||
Files in this directory are bundled as static assets for the AI provider
|
||||
configuration UI.
|
||||
|
||||
## Originally authored for netcatty
|
||||
|
||||
- `anthropic.svg`, `openai.svg`, `google.svg`, `ollama.svg`, `openrouter.svg`,
|
||||
`custom.svg` — netcatty.
|
||||
|
||||
## From [lobehub/lobe-icons](https://github.com/lobehub/lobe-icons) (MIT)
|
||||
|
||||
Copyright (c) 2023 LobeHub. Used under the MIT License. The full text is
|
||||
reproduced below and applies to:
|
||||
|
||||
- `deepseek.svg`
|
||||
- `moonshot.svg`
|
||||
- `kimi.svg`
|
||||
- `qwen.svg`
|
||||
- `zhipu.svg`
|
||||
- `doubao.svg`
|
||||
- `mistral.svg`
|
||||
- `cohere.svg`
|
||||
- `grok.svg`
|
||||
- `perplexity.svg`
|
||||
- `groq.svg`
|
||||
- `huggingface.svg`
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
Brand logos themselves remain the property of their respective owners.
|
||||
1
public/ai/providers/cohere.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z"></path></svg>
|
||||
|
After Width: | Height: | Size: 724 B |
1
public/ai/providers/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
public/ai/providers/doubao.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill-opacity=".5"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002zM14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill-opacity=".5"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/ai/providers/grok.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
1
public/ai/providers/groq.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 568 B |
1
public/ai/providers/huggingface.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>HuggingFace</title><path d="M16.781 3.277c2.997 1.704 4.844 4.851 4.844 8.258 0 .995-.155 1.955-.443 2.857a1.332 1.332 0 011.125.4 1.41 1.41 0 01.2 1.723c.204.165.352.385.428.632l.017.062c.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.255.57-.893 1.018-2.128 1.5l-.202.078-.131.048c-.478.173-.89.295-1.061.345l-.086.024c-.89.243-1.808.375-2.732.394-1.32 0-2.3-.36-2.923-1.067a9.852 9.852 0 01-3.18.018C9.778 21.647 8.802 22 7.494 22a11.249 11.249 0 01-2.541-.343l-.221-.06-.273-.08a16.574 16.574 0 01-1.175-.405c-1.237-.483-1.875-.93-2.13-1.501-.186-.4-.151-.867.093-1.236a1.42 1.42 0 01-.2-1.166c.069-.273.226-.516.447-.694a1.41 1.41 0 01.2-1.722c.233-.248.557-.391.917-.407l.078-.001a9.385 9.385 0 01-.44-2.85c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0zM4.188 14.758c.125.687 2.357 2.35 2.14 2.707-.19.315-.796-.239-.948-.386l-.041-.04-.168-.147c-.561-.479-2.304-1.9-2.74-1.432-.43.46.119.859 1.055 1.42l.784.467.136.083c1.045.643 1.12.84.95 1.113-.188.295-3.07-2.1-3.34-1.083-.27 1.011 2.942 1.304 2.744 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725l.16.04.175.042c1.227.284 3.565.65 4.435-.604.673-.973.64-1.709-.248-2.61l-.057-.057c-.945-.928-1.495-2.288-1.495-2.288l-.017-.058-.025-.072c-.082-.22-.284-.639-.63-.584-.46.073-.798 1.21.12 1.933l.05.038c.977.721-.195 1.21-.573.534l-.058-.104-.143-.25c-.463-.799-1.282-2.111-1.739-2.397-.532-.332-.907-.148-.782.541zm14.842-.541c-.533.335-1.563 2.074-1.94 2.751a.613.613 0 01-.687.302.436.436 0 01-.176-.098.303.303 0 01-.049-.06l-.014-.028-.008-.02-.007-.019-.003-.013-.003-.017a.289.289 0 01-.004-.048c0-.12.071-.266.25-.427.026-.024.054-.047.084-.07l.047-.036c.022-.016.043-.032.063-.049.883-.71.573-1.81.131-1.917l-.031-.006-.056-.004a.368.368 0 00-.062.006l-.028.005-.042.014-.039.017-.028.015-.028.019-.036.027-.023.02c-.173.158-.273.428-.31.542l-.016.054s-.53 1.309-1.439 2.234l-.054.054c-.365.358-.596.69-.702 1.018-.143.437-.066.868.21 1.353.055.097.117.195.187.296.882 1.275 3.282.876 4.494.59l.286-.07.25-.074c.276-.084.736-.233 1.2-.42l.188-.077.065-.028.064-.028.124-.056.081-.038c.529-.252.964-.543.994-.827l.001-.036a.299.299 0 00-.037-.139c-.094-.176-.271-.212-.491-.168l-.045.01c-.044.01-.09.024-.136.04l-.097.035-.054.022c-.559.23-1.238.705-1.607.745h.006a.452.452 0 01-.05.003h-.024l-.024-.003-.023-.005c-.068-.016-.116-.06-.14-.142a.22.22 0 01-.005-.1c.062-.345.958-.595 1.713-.91l.066-.028c.528-.224.97-.483.985-.832v-.04a.47.47 0 00-.016-.098c-.048-.18-.175-.251-.36-.251-.785 0-2.55 1.36-2.92 1.36-.025 0-.048-.007-.058-.024a.6.6 0 01-.046-.088c-.1-.238.068-.462 1.06-1.066l.209-.126c.538-.32 1.01-.588 1.341-.831.29-.212.475-.406.503-.6l.003-.028c.008-.113-.038-.227-.147-.344a.266.266 0 00-.07-.054l-.034-.015-.013-.005a.403.403 0 00-.13-.02c-.162 0-.369.07-.595.18-.637.313-1.431.952-1.826 1.285l-.249.215-.033.033c-.08.078-.288.27-.493.386l-.071.037-.041.019a.535.535 0 01-.122.036h.005a.346.346 0 01-.031.003l.01-.001-.013.001c-.079.005-.145-.021-.19-.095a.113.113 0 01-.014-.065c.027-.465 2.034-1.991 2.152-2.642l.009-.048c.1-.65-.271-.817-.791-.493zM11.938 2.984c-4.798 0-8.688 3.829-8.688 8.55 0 .692.083 1.364.24 2.008l.008-.009c.252-.298.612-.46 1.017-.46.355.008.699.117.993.312.22.14.465.384.715.694.261-.372.69-.598 1.15-.605.852 0 1.367.728 1.562 1.383l.047.105.06.127c.192.396.595 1.139 1.143 1.68 1.06 1.04 1.324 2.115.8 3.266a8.865 8.865 0 002.024-.014c-.505-1.12-.26-2.17.74-3.186l.066-.066c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694a1.87 1.87 0 01.99-.312c.357 0 .682.126.925.36.14-.61.215-1.245.215-1.898 0-4.722-3.89-8.55-8.687-8.55zm1.857 8.926l.439-.212c.553-.264.89-.383.89.152 0 1.093-.771 3.208-3.155 3.262h-.184c-2.325-.052-3.116-2.06-3.156-3.175l-.001-.087c0-1.107 1.452.586 3.25.586.716 0 1.379-.272 1.917-.526zm4.017-3.143c.45 0 .813.358.813.8 0 .441-.364.8-.813.8a.806.806 0 01-.812-.8c0-.442.364-.8.812-.8zm-11.624 0c.448 0 .812.358.812.8 0 .441-.364.8-.812.8a.806.806 0 01-.813-.8c0-.442.364-.8.813-.8zm7.79-.841c.32-.384.846-.54 1.33-.394.483.146.83.564.878 1.06.048.495-.212.97-.659 1.203-.322.168-.447-.477-.767-.585l.002-.003c-.287-.098-.772.362-.925.079a1.215 1.215 0 01.14-1.36zm-4.323 0c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003c-.108.036-.194.134-.273.24l-.118.165c-.11.15-.22.262-.377.18a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
public/ai/providers/kimi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
1
public/ai/providers/mistral.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>
|
||||
|
After Width: | Height: | Size: 418 B |
1
public/ai/providers/moonshot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
public/ai/providers/perplexity.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z"></path></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
1
public/ai/providers/qwen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
public/ai/providers/zhipu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |