Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8aa08994 | ||
|
|
fb522c5016 | ||
|
|
7272f2564d | ||
|
|
07a2f3a899 | ||
|
|
399e6a6f2d | ||
|
|
46d1cf1696 | ||
|
|
5be9bb58df | ||
|
|
cab4fc36ab | ||
|
|
53d3e05bb4 | ||
|
|
0c4de74c84 | ||
|
|
2a4feea40f | ||
|
|
faa90e1aa5 | ||
|
|
1aa96c3490 | ||
|
|
0e80955e96 | ||
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 | ||
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 | ||
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 | ||
|
|
f4aa6ddb46 | ||
|
|
f6cb73fdd6 | ||
|
|
3c100b0ae2 | ||
|
|
168e42b5fa | ||
|
|
2ce6bd5ed1 | ||
|
|
7bd5d6465a | ||
|
|
65387d4c61 | ||
|
|
6084e8e94f | ||
|
|
3ccc5c9fc6 | ||
|
|
d07859f604 | ||
|
|
88a322a03b | ||
|
|
0e02bbc2fb | ||
|
|
affd9217e2 | ||
|
|
7b4a349e3f | ||
|
|
7dc5ab5035 | ||
|
|
3e8965f9a9 | ||
|
|
23a27bf544 |
39
App.tsx
39
App.tsx
@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
@@ -1192,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
|
||||
@@ -1377,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,
|
||||
});
|
||||
|
||||
@@ -1397,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);
|
||||
@@ -1476,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();
|
||||
@@ -1727,7 +1728,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
updateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
@@ -1756,15 +1759,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
||||
connectionLogs,
|
||||
{ sessionId, hostname: session?.hostname },
|
||||
);
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
@@ -2145,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',
|
||||
@@ -301,6 +305,9 @@ const en: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
@@ -359,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',
|
||||
@@ -1140,10 +1150,23 @@ const en: Messages = {
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
|
||||
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
|
||||
'hostDetails.algorithms.customized': 'customized',
|
||||
'hostDetails.algorithms.reset': 'Reset',
|
||||
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Cipher',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
|
||||
'hostDetails.algorithms.category.compress': 'Compression',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Override global keepalive',
|
||||
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
|
||||
@@ -1890,6 +1913,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...',
|
||||
@@ -1935,13 +1970,20 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'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
|
||||
@@ -2012,6 +2054,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',
|
||||
@@ -2092,6 +2136,11 @@ const en: Messages = {
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
|
||||
@@ -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': 'Клавиатура',
|
||||
@@ -301,6 +305,9 @@ const ru: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
@@ -359,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': 'Сбросить встроенные правила по умолчанию',
|
||||
@@ -469,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': 'Вставить файл',
|
||||
@@ -1176,10 +1187,23 @@ const ru: Messages = {
|
||||
'hostDetails.deviceType': 'Режим сетевого устройства',
|
||||
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.legacyAlgorithms': 'Устаревшие алгоритмы',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Пропустить ECDSA host key',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Дополнительные настройки алгоритмов',
|
||||
'hostDetails.algorithms.advanced.desc': 'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
|
||||
'hostDetails.algorithms.customized': 'настроено',
|
||||
'hostDetails.algorithms.reset': 'Сбросить',
|
||||
'hostDetails.algorithms.category.kex': 'Обмен ключами (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Шифр',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
|
||||
'hostDetails.algorithms.category.compress': 'Сжатие',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Переопределить глобальный keepalive',
|
||||
'hostDetails.keepalive.desc': 'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
|
||||
@@ -1922,6 +1946,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': 'Расшифровка...',
|
||||
@@ -1967,13 +2003,20 @@ const ru: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Использует claude-agent-acp для потоковой передачи по протоколу ACP.',
|
||||
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
|
||||
'ai.claude.detecting': 'Обнаружение...',
|
||||
'ai.claude.detected': 'Обнаружен',
|
||||
'ai.claude.notFound': 'Не найден',
|
||||
'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
|
||||
@@ -2044,6 +2087,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': 'Без названия',
|
||||
@@ -2124,6 +2169,11 @@ const ru: Messages = {
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
|
||||
|
||||
@@ -749,10 +749,23 @@ const zhCN: Messages = {
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.skipEcdsaHostKey': '跳过 ECDSA 主机密钥',
|
||||
'hostDetails.skipEcdsaHostKey.desc': '某些老款华为 / 思科交换机的 ECDSA 主机密钥签名不规范,会导致连接报 "signature verification failed"。开启后客户端不再 advertise ecdsa-sha2-*,强制使用 RSA / Ed25519。',
|
||||
'hostDetails.algorithms.advanced': '高级算法配置',
|
||||
'hostDetails.algorithms.advanced.desc': '针对单个 host 自定义各分类的算法清单。不勾选 = 使用默认;勾选子集后将完全替换默认列表。配置错误可能导致无法连接。',
|
||||
'hostDetails.algorithms.inheritedNotice': '当前组已设置以下分类的算法 override:{categories}。本面板的"恢复默认"只会回到组的设置,而不是 NetCatty 默认列表。若要忽略组的限制,请到组的算法设置里取消。',
|
||||
'hostDetails.algorithms.customized': '已自定义',
|
||||
'hostDetails.algorithms.reset': '恢复默认',
|
||||
'hostDetails.algorithms.category.kex': '密钥交换 (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': '加密算法 (Cipher)',
|
||||
'hostDetails.algorithms.category.hmac': '完整性算法 (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': '主机密钥 (Host Key)',
|
||||
'hostDetails.algorithms.category.compress': '压缩 (Compression)',
|
||||
'hostDetails.section.keepalive': '会话保活',
|
||||
'hostDetails.keepalive.override': '为此主机单独配置',
|
||||
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
|
||||
@@ -1409,6 +1422,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': '键盘',
|
||||
@@ -1445,6 +1462,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
@@ -1497,6 +1516,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': '把内置规则恢复为默认',
|
||||
@@ -1597,6 +1619,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': '粘贴文件',
|
||||
@@ -1899,6 +1922,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': '解密中...',
|
||||
@@ -1944,13 +1979,20 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'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
|
||||
@@ -2021,6 +2063,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': '无标题',
|
||||
@@ -2101,6 +2145,11 @@ const zhCN: Messages = {
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
|
||||
20
application/state/aiStateEvents.ts
Normal file
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 } }));
|
||||
}
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
test("workspace + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
test("workspace with no focused session → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
|
||||
|
||||
test("open: closed with a remembered tab → open that tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "sftp" });
|
||||
});
|
||||
|
||||
test("open: closed with no memory → open the fallback tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "scripts" });
|
||||
});
|
||||
|
||||
test("close: already open → close", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
|
||||
assert.deepEqual(r, { kind: "close" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type SidePanelToggleIntent<T extends string> =
|
||||
| { kind: 'close' }
|
||||
| { kind: 'open'; tab: T };
|
||||
|
||||
/**
|
||||
* Decide what the "toggle side panel" shortcut should do.
|
||||
* - If a panel is open → close it.
|
||||
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
|
||||
* `fallbackTab` when the tab has no remembered panel.
|
||||
*/
|
||||
export function resolveSidePanelToggleIntent<T extends string>(input: {
|
||||
isOpen: boolean;
|
||||
lastTab: T | null;
|
||||
fallbackTab: T;
|
||||
}): SidePanelToggleIntent<T> {
|
||||
if (input.isOpen) return { kind: 'close' };
|
||||
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("backend exited events keep the tab and mark it disconnected", () => {
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
_evt: TerminalSessionExitEvent,
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
|
||||
// Explicit user closes bypass this policy and call the close-session path directly.
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: jumpHost.algorithms,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -159,6 +162,13 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
// even though the terminal session works.
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
skipEcdsaHostKey: host.skipEcdsaHostKey,
|
||||
algorithmOverrides: host.algorithms,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,14 +52,19 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +100,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -402,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ export const useKeychainBackend = () => {
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
// Algorithm settings — let the keychain "export public key" flow honor
|
||||
// the same per-host SSH algorithm config the terminal uses, so a host
|
||||
// that needs the ECDSA skip / legacy mode / advanced overrides works
|
||||
// here too.
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
|
||||
@@ -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,10 +165,11 @@ 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',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
@@ -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(
|
||||
@@ -637,6 +700,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
// If the probe came back empty, drop any stale cached catalog for this
|
||||
@@ -858,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') {
|
||||
@@ -869,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,
|
||||
@@ -886,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();
|
||||
@@ -927,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,
|
||||
@@ -947,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,
|
||||
@@ -1126,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}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
@@ -34,6 +36,8 @@ import {
|
||||
SSHKey,
|
||||
} from "../types";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import {
|
||||
ChainPanel,
|
||||
EnvVarsPanel,
|
||||
@@ -50,6 +54,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";
|
||||
@@ -112,7 +117,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
@@ -128,6 +133,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
const [addProtocolOpen, setAddProtocolOpen] = useState(false);
|
||||
|
||||
// Credential selection state
|
||||
@@ -172,6 +178,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.skipEcdsaHostKey;
|
||||
delete next.algorithms;
|
||||
delete next.backspaceBehavior;
|
||||
delete next.proxyProfileId;
|
||||
delete next.proxyConfig;
|
||||
@@ -313,6 +321,36 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return terminalThemeId;
|
||||
return resolveGroupTerminalThemeId(resolveGroupDefaults(parentGroup, groupConfigs), terminalThemeId);
|
||||
}, [groupConfigs, parentGroup, terminalThemeId]);
|
||||
|
||||
// Effective `legacyAlgorithms` for this group, considering inheritance
|
||||
// from the parent chain. Used by the algorithm-overrides editor so the
|
||||
// seed reflects what hosts in this group would actually advertise — if
|
||||
// the parent group already turned legacy mode on, the editor should
|
||||
// include legacy algorithms in its default list even when this group
|
||||
// itself hasn't set the flag.
|
||||
const inheritedLegacyAlgorithms = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).legacyAlgorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// Same idea for the algorithm-override lists themselves: surface what
|
||||
// this group would inherit from its parent so the editor can warn that
|
||||
// a local Reset falls back to the parent's lists, not NetCatty's
|
||||
// defaults.
|
||||
const inheritedAlgorithmOverrides = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return undefined;
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).algorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// And for the per-flag toggles below — if the parent already turned
|
||||
// a flag on, the runtime applies it to hosts in this group via
|
||||
// `applyGroupDefaults`, so the local toggle must reflect that. Without
|
||||
// this, a child group would show the flag as off while connections
|
||||
// still negotiated with it.
|
||||
const inheritedSkipEcdsaHostKey = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -361,6 +399,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.skipEcdsaHostKey !== undefined && { skipEcdsaHostKey: form.skipEcdsaHostKey }),
|
||||
...(form.algorithms !== undefined && { algorithms: form.algorithms }),
|
||||
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
|
||||
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
|
||||
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
|
||||
@@ -861,37 +901,69 @@ 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 */}
|
||||
{/* Display the *effective* value (this group's field falling
|
||||
back to the resolved parent default). Same rationale as
|
||||
in HostDetailsPanel — without the fallback, a child group
|
||||
that inherits a flag from a parent would show "off" in
|
||||
the UI while connections still applied it. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
enabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Backspace behavior */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
</p>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
inheritedFromGroup={inheritedAlgorithmOverrides}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
@@ -977,6 +1049,25 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderLock,
|
||||
@@ -55,6 +56,8 @@ import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyP
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
@@ -213,6 +216,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
@@ -1797,21 +1801,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
{/* SSH Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.sshAlgorithms")}</p>
|
||||
</div>
|
||||
{/* Display the *effective* value of these toggles (host field
|
||||
falling back to the resolved group default). Without the
|
||||
fallback a host that inherits the flag from its group would
|
||||
show "off" while the runtime applied it anyway, and the
|
||||
toggle's onToggle handler would compute the wrong "next"
|
||||
value from the raw host field. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
@@ -1819,6 +1832,61 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
</p>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
/* Use the effective legacy flag (host value falling back to
|
||||
the currently selected group's default) so the seed
|
||||
reflects what the host would actually advertise. We
|
||||
read from `effectiveGroupDefaults` (re-resolved on
|
||||
every form.group change), not the `groupDefaults` prop
|
||||
— otherwise switching the host into a different group
|
||||
without saving first would seed from the original
|
||||
group's flag and silently mis-populate the override. */
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.terminalBehavior")}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<Select
|
||||
|
||||
@@ -22,6 +22,8 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
@@ -75,6 +77,13 @@ interface KeychainManagerProps {
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
/**
|
||||
* Group default configurations. Needed by the "export public key to
|
||||
* host" flow so per-host SSH algorithm settings (legacy / skipEcdsa /
|
||||
* overrides) that the host inherits from its group are honored when
|
||||
* the export opens its one-off SSH connection.
|
||||
*/
|
||||
groupConfigs?: GroupConfig[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
@@ -92,6 +101,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
groupConfigs = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
@@ -1069,11 +1079,22 @@ echo $3 >> "$FILE"`);
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Resolve the effective host (applying group
|
||||
// defaults), so algorithm settings inherited from
|
||||
// the group reach the bridge — the host object on
|
||||
// its own only carries explicitly set fields.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: exportHost.hostname,
|
||||
hostname: effectiveExportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
port: effectiveExportHost.port || 22,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
@@ -1082,10 +1103,17 @@ echo $3 >> "$FILE"`);
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
// the interactive terminal does.
|
||||
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
|
||||
@@ -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,8 +5,8 @@ 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 { detectLocalOs } from "../lib/localShell";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
@@ -49,17 +49,20 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
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";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
markPromptLineBreakCommandPending,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -68,7 +71,8 @@ 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;
|
||||
|
||||
@@ -171,6 +175,7 @@ interface TerminalProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
@@ -261,6 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
@@ -281,6 +287,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -293,7 +300,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>("");
|
||||
@@ -314,21 +321,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) => {
|
||||
@@ -383,10 +384,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;
|
||||
@@ -514,10 +518,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
markPromptLineBreakCommandPending(promptLineBreakStateRef);
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
@@ -531,35 +539,58 @@ 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: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
});
|
||||
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
||||
// its state updates don't re-render this component (see render below).
|
||||
// For local protocol the effective OS is the client OS: synthetic fallback
|
||||
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
|
||||
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
|
||||
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
|
||||
? detectLocalOs(navigator.userAgent || navigator.platform)
|
||||
: (host.os || "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;
|
||||
|
||||
// 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;
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback(() => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
knownCwdRef.current = undefined;
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
|
||||
useEffect(() => {
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
clearTerminalCwd();
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
@@ -569,10 +600,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
@@ -584,37 +626,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
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";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -864,6 +891,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
clearTerminalCwd();
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
@@ -887,7 +915,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onOsDetected,
|
||||
@@ -899,7 +930,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;
|
||||
@@ -941,7 +972,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLineBufferRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onCwdChange: (cwd: string) => {
|
||||
terminalCwdTracker.setRendererCwd(cwd);
|
||||
knownCwdRef.current = cwd;
|
||||
onTerminalCwdChange?.(sessionId, cwd);
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
@@ -1206,11 +1239,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
@@ -1223,6 +1263,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -1536,10 +1588,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
|
||||
@@ -1580,17 +1643,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleOpenSFTP = async () => {
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -1803,17 +1856,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -2342,29 +2385,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 && (
|
||||
@@ -2455,6 +2498,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -35,6 +35,8 @@ const baseProps = {
|
||||
onAddKnownHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isBroadcastEnabled: () => true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, onToggleBroadcast: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,9 +57,11 @@ 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';
|
||||
import { resolvePreferredTerminalCwd } from './terminal/sftpCwd';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -380,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}
|
||||
@@ -464,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 {
|
||||
@@ -504,6 +507,7 @@ interface TerminalPaneProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts: () => void;
|
||||
onOpenTheme: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
@@ -564,6 +568,7 @@ const terminalPanePropsAreEqual = (
|
||||
prev.sessionLog === next.sessionLog &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onOpenSftp === next.onOpenSftp &&
|
||||
prev.onTerminalCwdChange === next.onTerminalCwdChange &&
|
||||
prev.onOpenScripts === next.onOpenScripts &&
|
||||
prev.onOpenTheme === next.onOpenTheme &&
|
||||
prev.onCloseSession === next.onCloseSession &&
|
||||
@@ -612,6 +617,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
sessionLog,
|
||||
onHotkeyAction,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
onCloseSession,
|
||||
@@ -727,6 +733,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onOpenSftp={onOpenSftp}
|
||||
onTerminalCwdChange={onTerminalCwdChange}
|
||||
onOpenScripts={onOpenScripts}
|
||||
onOpenTheme={onOpenTheme}
|
||||
onCloseSession={onCloseSession}
|
||||
@@ -783,6 +790,7 @@ interface TerminalPanesHostProps {
|
||||
sessionLog?: { enabled: true; directory: string; format: string };
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onOpenSftp: TerminalPaneProps['onOpenSftp'];
|
||||
onTerminalCwdChange: TerminalPaneProps['onTerminalCwdChange'];
|
||||
onOpenScripts: () => void;
|
||||
onOpenTheme: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
@@ -884,9 +892,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
toggleSidePanelRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -894,6 +901,24 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
const isSftpActive = activeTabId === 'sftp';
|
||||
const isVisible = (!isVaultActive && !isSftpActive) || !!draggingSessionId;
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
|
||||
if (cwd && cwd.trim().length > 0) {
|
||||
terminalRendererCwdBySessionRef.current.set(sessionId, cwd);
|
||||
} else {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
for (const sessionId of terminalRendererCwdBySessionRef.current.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Stable callback references for Terminal components
|
||||
const handleCloseSession = useCallback((sessionId: string) => {
|
||||
@@ -954,10 +979,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(evt);
|
||||
if (intent.kind === "markDisconnected") {
|
||||
if (intent.kind === "closeSession") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
}, [onCloseSession, onUpdateSessionStatus]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
@@ -1075,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';
|
||||
|
||||
@@ -1322,16 +1354,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
} else {
|
||||
// Create stable fallback host object
|
||||
const fallbackProtocol = session.protocol ?? 'local' as const;
|
||||
map.set(session.id, {
|
||||
id: session.hostId,
|
||||
label: session.hostLabel || 'Local Terminal',
|
||||
hostname: session.hostname || 'localhost',
|
||||
username: session.username || 'local',
|
||||
port: session.port ?? 22,
|
||||
os: 'linux',
|
||||
// Only local terminals adopt the client OS — unsaved serial
|
||||
// sessions and orphaned remote sessions (whose host was deleted
|
||||
// while the session lives on) also hit this fallback, and the
|
||||
// non-local autocomplete path in Terminal.tsx trusts host.os, so
|
||||
// a Windows-client 'windows' tag here would mis-shape POSIX
|
||||
// remote/serial autocomplete (#1112 review).
|
||||
os: fallbackProtocol === 'local'
|
||||
? detectLocalOs(navigator.userAgent || navigator.platform)
|
||||
: 'linux',
|
||||
group: '',
|
||||
tags: [],
|
||||
protocol: session.protocol ?? 'local' as const,
|
||||
protocol: fallbackProtocol,
|
||||
moshEnabled: session.moshEnabled,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
@@ -1772,13 +1813,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
return result.success && result.cwd ? result.cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
sessionId,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
@@ -1821,13 +1860,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) => {
|
||||
@@ -1840,16 +1889,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);
|
||||
@@ -1867,7 +1907,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, tab);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [resolveSftpHostForTab]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
@@ -1907,6 +1947,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');
|
||||
@@ -2220,14 +2288,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
if (rawFocusedHost) {
|
||||
onUpdateHost({ ...rawFocusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
@@ -2236,14 +2306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
if (rawFocusedHost) {
|
||||
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
@@ -3128,6 +3200,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLog={sessionLogConfig}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onOpenSftp={handleOpenSftp}
|
||||
onTerminalCwdChange={handleTerminalCwdChange}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onOpenTheme={handleOpenTheme}
|
||||
onCloseSession={handleCloseSession}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
|
||||
142
components/VaultView.sortPersistence.test.tsx
Normal file
142
components/VaultView.sortPersistence.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Host, SSHKey } from "../types.ts";
|
||||
import { VaultView } from "./VaultView.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const installStorageStub = (sortMode: string | null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (sortMode !== null) {
|
||||
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
|
||||
id,
|
||||
label,
|
||||
hostname: `${id}.example.com`,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
createdAt,
|
||||
group,
|
||||
});
|
||||
|
||||
const fallbackKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Fallback key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
installStorageStub(sortMode);
|
||||
const noop = () => {};
|
||||
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onToggleConnectionLogSaved: noop,
|
||||
onDeleteConnectionLog: noop,
|
||||
onClearUnsavedConnectionLogs: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("Hosts sort mode is restored from storage", () => {
|
||||
const markup = renderVault("za", [
|
||||
host("alpha", "Alpha Host", 1),
|
||||
host("zulu", "Zulu Host", 2),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts grouped sort mode is restored from storage", () => {
|
||||
const markup = renderVault("group", [
|
||||
host("beta", "Beta Host", 1, "Beta Group"),
|
||||
host("alpha", "Alpha Host", 2, "Alpha Group"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
|
||||
const markup = renderVault("unknown-sort", [
|
||||
host("zulu", "Zulu Host", 2),
|
||||
host("alpha", "Alpha Host", 1),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../application/state/useStoredString";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -121,6 +123,13 @@ type DropTarget =
|
||||
| { kind: "root" }
|
||||
| { kind: "group"; path: string };
|
||||
|
||||
const isSortMode = (value: string): value is SortMode =>
|
||||
value === "az" ||
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "group";
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
@@ -280,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
"az",
|
||||
isSortMode,
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
@@ -2902,6 +2915,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts={hosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
onUpdate={(k) =>
|
||||
|
||||
@@ -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,16 +388,37 @@ 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 =
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
'h-6 w-6 shrink-0 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
@@ -564,7 +618,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<PromptInputTools className="gap-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -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');
|
||||
@@ -663,12 +717,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
className={`${chipClassName} min-w-0 ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
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 min-w-0 ${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 (
|
||||
@@ -769,7 +873,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
className={`${chipClassName} shrink-0 cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
aria-label={t('ai.safety.permissionMode')}
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
|
||||
61
components/ai/claudeConfigEnv.test.ts
Normal file
61
components/ai/claudeConfigEnv.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
splitClaudeEnv,
|
||||
buildClaudeEnv,
|
||||
parseEnvLines,
|
||||
serializeEnvLines,
|
||||
} from "../settings/tabs/ai/claudeConfigEnv";
|
||||
|
||||
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
|
||||
const result = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(result.configDir, "/cfg");
|
||||
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
});
|
||||
|
||||
test("splitClaudeEnv handles undefined env", () => {
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
|
||||
});
|
||||
|
||||
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
|
||||
assert.deepEqual(
|
||||
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
|
||||
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeEnvLines is the inverse for simple entries", () => {
|
||||
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
|
||||
});
|
||||
|
||||
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
|
||||
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
|
||||
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
|
||||
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
const next = buildClaudeEnv(
|
||||
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
|
||||
"/cfg",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
|
||||
);
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -143,6 +144,7 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
@@ -646,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: '',
|
||||
|
||||
@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'claude',
|
||||
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
effectiveDefaultAlgorithms,
|
||||
SSH_ALGORITHM_CATEGORIES,
|
||||
SSHAlgorithmCategory,
|
||||
SUPPORTED_ALGORITHMS_BY_CATEGORY,
|
||||
} from "../../domain/sshAlgorithmList";
|
||||
import type { HostAlgorithmOverrides } from "../../domain/models";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
value: HostAlgorithmOverrides | undefined;
|
||||
onChange: (next: HostAlgorithmOverrides | undefined) => void;
|
||||
/**
|
||||
* The host's current `legacyAlgorithms` value, used to seed the very
|
||||
* first customization in each category with the *effective* default
|
||||
* list (modern-only vs modern+legacy) rather than the full SUPPORTED
|
||||
* set. Without this, unchecking a single algorithm in modern mode
|
||||
* would silently start advertising CBC / arcfour / MD5 algorithms.
|
||||
*/
|
||||
legacyEnabled: boolean;
|
||||
/**
|
||||
* Algorithm overrides this host would inherit from its group when its
|
||||
* own field is unset. Used purely for display: an `undefined` value
|
||||
* here means the host can freely use NetCatty defaults by resetting
|
||||
* a category; a populated value means the host would inherit those
|
||||
* lists, and resetting locally falls back to them — the panel
|
||||
* surfaces that so the user knows the local Reset button doesn't
|
||||
* jump them to NetCatty's defaults in that case.
|
||||
*/
|
||||
inheritedFromGroup?: HostAlgorithmOverrides;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<SSHAlgorithmCategory, string> = {
|
||||
kex: "hostDetails.algorithms.category.kex",
|
||||
cipher: "hostDetails.algorithms.category.cipher",
|
||||
hmac: "hostDetails.algorithms.category.hmac",
|
||||
serverHostKey: "hostDetails.algorithms.category.serverHostKey",
|
||||
compress: "hostDetails.algorithms.category.compress",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-category SSH algorithm override editor.
|
||||
*
|
||||
* When a category's array is `undefined`, that category uses NetCatty's
|
||||
* negotiated default list. When it's a non-empty array, that array fully
|
||||
* replaces the offered list for the category.
|
||||
*
|
||||
* Picking zero algorithms in a category is equivalent to "use default" —
|
||||
* an empty array would make ssh2 fail negotiation, so we normalize it
|
||||
* back to `undefined` on save.
|
||||
*/
|
||||
export const AlgorithmOverridesPanel: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
legacyEnabled,
|
||||
inheritedFromGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const effectiveDefault = useMemo(
|
||||
() => effectiveDefaultAlgorithms(legacyEnabled),
|
||||
[legacyEnabled],
|
||||
);
|
||||
// What the runtime *actually* inherits from the group for display
|
||||
// purposes. `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing boundary: once the host carries any local
|
||||
// `algorithms` object the group's overrides stop being applied — even
|
||||
// for categories the host didn't override. So as soon as `value` is
|
||||
// non-undefined we must stop *displaying* inherited categories,
|
||||
// otherwise the UI lies about what will be negotiated.
|
||||
//
|
||||
// The write-side (`updateCategory` / `toggleAlgorithm` / Reset) still
|
||||
// consults the unconditional `inheritedFromGroup` so that the first
|
||||
// user edit on an unset host carries the inherited categories into
|
||||
// the host object, preventing the runtime's silent widening that
|
||||
// motivated those write-side fixes.
|
||||
const inheritedForDisplay = useMemo(
|
||||
() => (value === undefined ? inheritedFromGroup : undefined),
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
const inheritedCategories = useMemo(() => {
|
||||
if (!inheritedForDisplay) return [] as SSHAlgorithmCategory[];
|
||||
return SSH_ALGORITHM_CATEGORIES.filter((category) => {
|
||||
const list = inheritedForDisplay[category];
|
||||
return Array.isArray(list) && list.length > 0;
|
||||
});
|
||||
}, [inheritedForDisplay]);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory, selected: string[]) => {
|
||||
// Start from the inherited group overrides so that touching one
|
||||
// category doesn't silently drop inheritance for the others.
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary: once the host carries any
|
||||
// explicit object, the host's `algorithms` shadows the group's
|
||||
// `algorithms` entirely. If the user customized cipher locally
|
||||
// and the group restricted serverHostKey, simply storing
|
||||
// `{ cipher: [...] }` on the host would lose the group's
|
||||
// serverHostKey restriction. Persisting the inherited categories
|
||||
// alongside keeps the effective offer intact.
|
||||
const base: HostAlgorithmOverrides = inheritedFromGroup
|
||||
? { ...inheritedFromGroup }
|
||||
: {};
|
||||
const next: HostAlgorithmOverrides = { ...base, ...(value ?? {}) };
|
||||
if (selected.length === 0) {
|
||||
delete next[category];
|
||||
} else {
|
||||
next[category] = selected;
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const toggleAlgorithm = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (!current) {
|
||||
// First click in this category — seed with the *effective* offer
|
||||
// for this category. If the group has set a list for this
|
||||
// category, use that (so customizing one entry doesn't lose the
|
||||
// group's narrowing). Otherwise seed from NetCatty's effective
|
||||
// default, which already accounts for legacy mode. Seeding from
|
||||
// SUPPORTED_ALGORITHMS_BY_CATEGORY would silently introduce
|
||||
// legacy algorithms (CBC, arcfour, MD5) into the offered list.
|
||||
const baseline = inheritedFromGroup?.[category] ?? effectiveDefault[category];
|
||||
if (baseline.includes(algo)) {
|
||||
updateCategory(category, baseline.filter((a) => a !== algo));
|
||||
} else {
|
||||
// The user clicked an algorithm not in the baseline — they
|
||||
// want to opt INTO it. Start the override with the baseline
|
||||
// plus this extra entry.
|
||||
updateCategory(category, [...baseline, algo]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.includes(algo)) {
|
||||
updateCategory(category, current.filter((a) => a !== algo));
|
||||
} else {
|
||||
updateCategory(category, [...current, algo]);
|
||||
}
|
||||
},
|
||||
[value, updateCategory, effectiveDefault, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const resetCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
const next: HostAlgorithmOverrides = { ...(value ?? {}) };
|
||||
if (Array.isArray(inherited) && inherited.length > 0) {
|
||||
// The group has an override for this category. Just deleting
|
||||
// `next[category]` would *widen* the effective offer: because
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary, once any other category
|
||||
// remains on the host the group's `algorithms` object stops
|
||||
// being inherited as a whole, and the missing category falls
|
||||
// back to NetCatty defaults — not the group's narrower list.
|
||||
// Persist the inherited list verbatim instead, so Reset means
|
||||
// "use what this host would otherwise inherit" rather than
|
||||
// "silently switch to NetCatty defaults".
|
||||
next[category] = inherited.slice();
|
||||
} else {
|
||||
delete next[category];
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isCustomized = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const local = value?.[category];
|
||||
if (!Array.isArray(local) || local.length === 0) return false;
|
||||
// If the host's list is identical (order + contents) to the
|
||||
// inherited list, the user hasn't really customized it — they
|
||||
// either reset to the upstream value or never touched it directly.
|
||||
// Suppressing the "customized" badge in that case keeps the UI
|
||||
// honest about what the user actually changed.
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
if (Array.isArray(inherited)
|
||||
&& inherited.length === local.length
|
||||
&& inherited.every((a, i) => a === local[i])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isChecked = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (current) return current.includes(algo);
|
||||
// No host-local override for this category: reflect what the host
|
||||
// would actually advertise. Uses `inheritedForDisplay` (the same
|
||||
// gating the inherited notice uses) so that a host that already
|
||||
// has any local override stops pretending its empty categories
|
||||
// still come from the group — `applyGroupDefaults` won't apply
|
||||
// them, and the runtime falls back to NetCatty defaults.
|
||||
const baseline = inheritedForDisplay?.[category] ?? effectiveDefault[category];
|
||||
return baseline.includes(algo);
|
||||
},
|
||||
[value, effectiveDefault, inheritedForDisplay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.algorithms.advanced.desc")}
|
||||
</p>
|
||||
{inheritedCategories.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 break-words">
|
||||
{t("hostDetails.algorithms.inheritedNotice")
|
||||
.replace(
|
||||
"{categories}",
|
||||
inheritedCategories.map((c) => t(CATEGORY_LABEL_KEY[c])).join(", "),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{SSH_ALGORITHM_CATEGORIES.map((category) => {
|
||||
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
|
||||
const customized = isCustomized(category);
|
||||
return (
|
||||
<Card key={category} className="p-2 space-y-1.5 bg-background border-border/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
{t(CATEGORY_LABEL_KEY[category])}
|
||||
{customized && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.algorithms.customized")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{customized && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => resetCategory(category)}
|
||||
>
|
||||
{t("hostDetails.algorithms.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{supported.map((algo) => (
|
||||
<label
|
||||
key={algo}
|
||||
className="flex items-center gap-2 text-[11px] cursor-pointer select-none hover:bg-accent/40 rounded px-1 py-0.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={isChecked(category, algo)}
|
||||
onChange={() => toggleAlgorithm(category, algo)}
|
||||
/>
|
||||
<span className="font-mono truncate" title={algo}>{algo}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
@@ -810,6 +894,12 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.keyboard.optionArrowWordJump")}
|
||||
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.accessibility")} />
|
||||
@@ -894,7 +984,7 @@ export default function SettingsTerminalTab(props: {
|
||||
label={t("settings.terminal.behavior.forcePromptNewLine")}
|
||||
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? true} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
@@ -990,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
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;
|
||||
}
|
||||
@@ -47,11 +47,15 @@ export function buildManagedAgentState(
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const managedEnv = agentKey === "claude"
|
||||
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
|
||||
: existingManaged?.env;
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
...(managedEnv ? { env: managedEnv } : {}),
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
|
||||
@@ -450,3 +450,32 @@ test("applyKeystroke: ignores non-typing data (escape sequences, control codes)"
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// We believe only "network in" is typed; suggestion is the full command.
|
||||
addon.show("network interface show", "network in");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// The real line shows MORE than we tracked: a bastion host echoed the
|
||||
// next char ("t") that our client-side buffer never recorded.
|
||||
const line = "ecOS# network int";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = line.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
118
components/terminal/TerminalAutocomplete.tsx
Normal file
118
components/terminal/TerminalAutocomplete.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import type { ComponentProps, RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import {
|
||||
useTerminalAutocomplete,
|
||||
AutocompletePopup,
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
type PopupProps = ComponentProps<typeof AutocompletePopup>;
|
||||
|
||||
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
|
||||
type HandlerRef<T> = { current: T | undefined };
|
||||
|
||||
interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
getCwd?: () => string | undefined;
|
||||
onAcceptText: (text: string) => void;
|
||||
snippets?: Snippet[];
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
/** Whether this terminal tab is the visible one. */
|
||||
visible: boolean;
|
||||
themeColors: PopupProps["themeColors"];
|
||||
containerRef: PopupProps["containerRef"];
|
||||
searchBarOffset: number;
|
||||
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
|
||||
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the terminal autocomplete hook and renders its popup.
|
||||
*
|
||||
* Kept as its own component so the frequent autocomplete state updates
|
||||
* (suggestions, selection, live-preview navigation) re-render only this small
|
||||
* subtree rather than the whole Terminal component. The hook's handlers are
|
||||
* surfaced back to Terminal through refs so the xterm runtime can call them.
|
||||
*
|
||||
* Must be mounted unconditionally for the terminal session's lifetime: the hook
|
||||
* records command history on Enter and intercepts completion keys even while no
|
||||
* popup is visible. Visibility only gates the rendered popup, not the hook.
|
||||
*/
|
||||
export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
getCwd,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
visible,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
keyEventRef,
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
}: TerminalAutocompleteProps) {
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
protocol,
|
||||
getCwd,
|
||||
});
|
||||
|
||||
// Surface the handlers for runtime wiring. They have stable identities
|
||||
// (useCallback over refs), so assigning during render is cheap and mirrors
|
||||
// the wiring Terminal did inline before this was extracted.
|
||||
keyEventRef.current = autocomplete.handleKeyEvent;
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Portal to body so the popup escapes the terminal container's overflow.
|
||||
return ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={state.suggestions}
|
||||
selectedIndex={state.selectedIndex}
|
||||
position={state.popupPosition}
|
||||
cursorLineTop={state.popupCursorLineTop}
|
||||
cursorLineBottom={state.popupCursorLineBottom}
|
||||
visible={state.popupVisible}
|
||||
expandUpward={state.expandUpward}
|
||||
themeColors={themeColors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={state.subDirPanels}
|
||||
subDirFocusLevel={state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={searchBarOffset}
|
||||
onDismiss={autocomplete.closePopup}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 */
|
||||
@@ -91,6 +92,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
@@ -327,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",
|
||||
@@ -361,6 +389,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -445,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>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
@@ -112,9 +113,16 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013), hide rather than draw the ghost over
|
||||
// already-typed text. Done here (post-echo render) rather than in
|
||||
// show()/adjustToInput so it never fights the keystroke-time path.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -291,6 +299,23 @@ export class GhostTextAddon implements IDisposable {
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the real terminal line has more input than we tracked, so
|
||||
* rendering the ghost would paint over already-typed characters. See
|
||||
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
|
||||
* we can't judge (non-ASCII, echo still catching up), so the ghost only
|
||||
* gets suppressed when corruption is actually imminent.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term || !this.currentInput) return false;
|
||||
const buf = this.term.buffer.active;
|
||||
if (typeof buf?.getLine !== "function") return false;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
if (!line || typeof line.translateToString !== "function") return false;
|
||||
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Fail-safe consistency check for inline (ghost-text) suggestions.
|
||||
*
|
||||
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
|
||||
* cursor, where `trackedInput` is what the client thinks the user has typed.
|
||||
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
|
||||
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
|
||||
* of sync with what is actually on the terminal line, and the ghost ends up
|
||||
* painted over characters the user already typed (`int` + ghost `terface` →
|
||||
* `intterface`).
|
||||
*
|
||||
* This detects the one direction that produces visible corruption: the real
|
||||
* line being AHEAD of the tracked input (it contains the tracked input
|
||||
* followed by more, untracked characters). SSH echo latency is the opposite
|
||||
* case — the line is a prefix-behind of the tracked input — and is
|
||||
* intentionally NOT flagged, so the ghost stays responsive on slow links.
|
||||
*
|
||||
* Returns true when the caller should hide the ghost.
|
||||
*/
|
||||
export function lineHasUntrackedTrailingInput(
|
||||
trackedInput: string,
|
||||
lineBeforeCursor: string,
|
||||
): boolean {
|
||||
// Single chars match too loosely to judge reliably; let them through.
|
||||
if (trackedInput.length < 2) return false;
|
||||
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
|
||||
// existing wide-char (CJK / emoji) handling is left untouched.
|
||||
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
|
||||
|
||||
// Use the last occurrence so a prompt or command that repeats the same token
|
||||
// earlier on the line doesn't shadow the freshly-typed input.
|
||||
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
|
||||
if (idx < 0) {
|
||||
// Tracked input isn't on screen yet — the echo is still catching up
|
||||
// (latency). Keep the ghost; reality being behind never corrupts.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-whitespace characters between the tracked input and the cursor mean the
|
||||
// device echoed input we never tracked → the ghost would overlap real text.
|
||||
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
|
||||
}
|
||||
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
@@ -20,7 +20,29 @@ const NON_PROMPT_PATTERNS = [
|
||||
/^:\s*$/, // vim command mode
|
||||
/^\s*~\s*$/, // vim tilde lines
|
||||
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
|
||||
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
|
||||
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
|
||||
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
|
||||
/^SQL>\s/i, // sqlplus SQL> prompts
|
||||
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
|
||||
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
|
||||
/^pry\([^)]*\)>\s/i,
|
||||
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
|
||||
/^lftp\s+\S+>\s/i,
|
||||
/^\s{3}\.{3}>\s/,
|
||||
/^cqlsh(?::[\w.-]+)?>\s/i,
|
||||
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
|
||||
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
|
||||
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
|
||||
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
|
||||
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
|
||||
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
|
||||
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
|
||||
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
|
||||
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
|
||||
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
|
||||
];
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
@@ -38,30 +60,48 @@ const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
(trimmed.endsWith(">") || trimmed.endsWith("›"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
|
||||
}
|
||||
|
||||
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (/^root[#%$]$/.test(trimmed)) return true;
|
||||
if (trimmed.length < 3) return false;
|
||||
|
||||
const marker = trimmed[trimmed.length - 1];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
|
||||
|
||||
const prev = trimmed[trimmed.length - 2] ?? "";
|
||||
return /[~:/\\\])]/.test(prev);
|
||||
}
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* actually matches the tail of the raw detected userInput. Returns
|
||||
* null otherwise — the single signal downstream uses to decide
|
||||
* whether to record it as the executed command.
|
||||
* can be validated against the live terminal line. Returns null
|
||||
* otherwise - the single signal downstream uses to decide whether
|
||||
* to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
}
|
||||
|
||||
function replacePromptUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
userInput: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText,
|
||||
userInput,
|
||||
cursorOffset: userInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorLinePrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
@@ -72,6 +112,499 @@ function getCursorLinePrefix(term: XTerm): string | null {
|
||||
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
|
||||
}
|
||||
|
||||
function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line?.isWrapped) return null;
|
||||
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) return null;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (!promptLine) return null;
|
||||
|
||||
let prefix = promptLine.translateToString(false);
|
||||
for (let row = promptRow + 1; row < cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (!rowLine) return null;
|
||||
prefix += rowLine.translateToString(false);
|
||||
}
|
||||
|
||||
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
|
||||
}
|
||||
|
||||
function inferPromptTextBeforeTypedInput(
|
||||
cursorPrefix: string,
|
||||
typedBuffer: string,
|
||||
allowPartialEcho: boolean,
|
||||
): string | null {
|
||||
if (cursorPrefix.endsWith(typedBuffer)) {
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
|
||||
return promptText.length > 0 ? promptText : null;
|
||||
}
|
||||
|
||||
if (!allowPartialEcho) return null;
|
||||
|
||||
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
|
||||
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
|
||||
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (promptText.length > 0) return promptText;
|
||||
}
|
||||
|
||||
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
|
||||
for (
|
||||
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
|
||||
echoLength >= noSpacePromptMinEchoLength;
|
||||
echoLength--
|
||||
) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
const hasReliablePartialEcho =
|
||||
typedBuffer.trim().length <= 2 ||
|
||||
echoedInput.endsWith(" ") ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4);
|
||||
if (!hasReliablePartialEcho) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
|
||||
const candidate = promptText.slice(0, promptBoundary).trimEnd();
|
||||
const finalIndex = candidate.length - 1;
|
||||
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
|
||||
|
||||
for (let i = 0; i < finalIndex; i++) {
|
||||
const ch = candidate[i];
|
||||
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
|
||||
|
||||
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
|
||||
if (nextChar === null || nextChar === " ") continue;
|
||||
|
||||
const earlierPrompt = candidate.slice(0, i + 1);
|
||||
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
|
||||
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
|
||||
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
|
||||
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
|
||||
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
|
||||
if (finalChar !== "$") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
|
||||
if (promptText.length === 0) return false;
|
||||
if (rawIsAtPrompt) return true;
|
||||
|
||||
const promptBoundary = findPromptBoundary(promptText);
|
||||
const promptEndsAtBoundary =
|
||||
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
return (
|
||||
promptEndsAtBoundary &&
|
||||
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
|
||||
isSpecificShellPromptCandidate(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isThemedPromptText(promptText: string): boolean {
|
||||
for (const ch of promptText) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return /[❯❮→➜➤⟩»›]/.test(promptText);
|
||||
}
|
||||
|
||||
function isPromptPathDecoration(trimmed: string): boolean {
|
||||
return (
|
||||
trimmed === "~" ||
|
||||
trimmed.startsWith("~/") ||
|
||||
trimmed.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
|
||||
trimmed.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptBareDirectoryText(trimmed: string): boolean {
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
|
||||
return /^[\w.-]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function isPromptStatusToken(token: string): boolean {
|
||||
return (
|
||||
/^git:\([^)]*\)$/.test(token) ||
|
||||
/^[+$#%>!?*]$/.test(token) ||
|
||||
token === "✗" ||
|
||||
token === "✔"
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptStatusText(trimmed: string): boolean {
|
||||
const [first = "", ...rest] = trimmed.split(/\s+/);
|
||||
if (rest.length === 0) return false;
|
||||
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
|
||||
return rest.every(isPromptStatusToken);
|
||||
}
|
||||
|
||||
function isPromptStatusDecoration(extra: string): boolean {
|
||||
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
|
||||
|
||||
return isPromptStatusText(extra.trim());
|
||||
}
|
||||
|
||||
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (!isThemedPromptText(promptText)) return false;
|
||||
if (startsWithCommonShellCommand(extra)) return false;
|
||||
if (/^\s*\S+\s+$/.test(extra)) {
|
||||
return isPromptPathDecoration(trimmed) || (
|
||||
isPromptBareDirectoryText(trimmed) &&
|
||||
!startsWithCommonShellCommand(trimmed)
|
||||
);
|
||||
}
|
||||
if (isPromptStatusDecoration(extra)) return true;
|
||||
for (const ch of extra) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFinalPromptBoundary(promptText: string): number {
|
||||
const trimmedEnd = promptText.trimEnd().length;
|
||||
if (trimmedEnd === 0) return -1;
|
||||
|
||||
const markerIndex = trimmedEnd - 1;
|
||||
const marker = promptText[markerIndex];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
|
||||
|
||||
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") return -1;
|
||||
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
|
||||
}
|
||||
|
||||
function endsAtFinalPromptBoundary(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
}
|
||||
|
||||
const COMMON_SHELL_COMMANDS = new Set([
|
||||
"alias",
|
||||
"awk",
|
||||
"az",
|
||||
"brew",
|
||||
"bun",
|
||||
"bundle",
|
||||
"cargo",
|
||||
"cat",
|
||||
"cd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"code",
|
||||
"composer",
|
||||
"cp",
|
||||
"curl",
|
||||
"docker",
|
||||
"echo",
|
||||
"emacs",
|
||||
"env",
|
||||
"export",
|
||||
"find",
|
||||
"gcloud",
|
||||
"gh",
|
||||
"git",
|
||||
"go",
|
||||
"gradle",
|
||||
"grep",
|
||||
"helm",
|
||||
"java",
|
||||
"javac",
|
||||
"kubectl",
|
||||
"less",
|
||||
"ls",
|
||||
"make",
|
||||
"mkdir",
|
||||
"mvn",
|
||||
"mv",
|
||||
"nano",
|
||||
"node",
|
||||
"npm",
|
||||
"npx",
|
||||
"nvim",
|
||||
"php",
|
||||
"pip",
|
||||
"pip3",
|
||||
"pnpm",
|
||||
"printf",
|
||||
"python",
|
||||
"python3",
|
||||
"rails",
|
||||
"rm",
|
||||
"rsync",
|
||||
"ruby",
|
||||
"rustc",
|
||||
"scp",
|
||||
"screen",
|
||||
"sed",
|
||||
"ssh",
|
||||
"sudo",
|
||||
"tail",
|
||||
"tar",
|
||||
"terraform",
|
||||
"tmux",
|
||||
"touch",
|
||||
"uv",
|
||||
"vi",
|
||||
"vim",
|
||||
"yarn",
|
||||
]);
|
||||
|
||||
function getLeadingShellCommandWord(text: string): string | null {
|
||||
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function startsWithCommonShellCommand(text: string): boolean {
|
||||
const command = getLeadingShellCommandWord(text);
|
||||
return command !== null && COMMON_SHELL_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
function isCompleteSpecificPrompt(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return (
|
||||
promptBoundary >= 0 &&
|
||||
promptText.slice(promptBoundary).trim().length === 0 &&
|
||||
isSpecificShellPromptCandidate(promptText) &&
|
||||
!isEmbeddedPromptMarker(promptText, promptBoundary)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
|
||||
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
|
||||
const trimmedStart = text.trimStart();
|
||||
if (startsWithCommonShellCommand(trimmedStart)) return true;
|
||||
|
||||
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
|
||||
return startsWithCommonShellCommand(afterDecoration);
|
||||
}
|
||||
|
||||
function isSingleBareDirectoryExtra(extra: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
|
||||
}
|
||||
|
||||
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
|
||||
return /^\s+\S+\s+$/.test(extra);
|
||||
}
|
||||
|
||||
type PromptDecorationReconcileOptions = {
|
||||
allowSingleWordCommandDirectory?: boolean;
|
||||
};
|
||||
|
||||
function canTreatCommonCommandNameAsThemedDirectory(
|
||||
extra: string,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
const trimmedInput = typedInput.trim();
|
||||
return (
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
(
|
||||
/\s/.test(trimmedInput) ||
|
||||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
|
||||
(
|
||||
options.allowSingleWordCommandDirectory === true &&
|
||||
hasExplicitThemedDirectorySpacing(extra)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canReconcilePromptDecoration(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
if (
|
||||
!prompt.isAtPrompt ||
|
||||
!typedInput ||
|
||||
prompt.userInput.length <= typedInput.length ||
|
||||
!prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
|
||||
if (
|
||||
isThemedPromptText(prompt.promptText) &&
|
||||
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatePromptText = prompt.promptText + extra;
|
||||
const promptEndsAtBoundary =
|
||||
endsAtFinalPromptBoundary(candidatePromptText) &&
|
||||
isSpecificShellPromptCandidate(candidatePromptText);
|
||||
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
|
||||
}
|
||||
|
||||
function alignTypedInputFromCursorPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
cursorPrefix: string | null,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!cursorPrefix) return null;
|
||||
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
|
||||
|
||||
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
|
||||
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function canUseReliablePromptPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): boolean {
|
||||
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typedBuffer.length <= raw.userInput.length) return false;
|
||||
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
|
||||
allowShortEcho: allowsShortPromptEcho(raw.promptText),
|
||||
});
|
||||
}
|
||||
|
||||
function isLikelyBareMongoPromptName(promptName: string): boolean {
|
||||
return /^(?:test|admin|local|config)$/i.test(promptName);
|
||||
}
|
||||
|
||||
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const promptName = trimmed.slice(0, -1).trim();
|
||||
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
|
||||
}
|
||||
|
||||
function endsWithStandardShellPrompt(promptText: string): boolean {
|
||||
const finalChar = promptText.trimEnd().at(-1);
|
||||
return finalChar === "$" || finalChar === "#" || finalChar === "%";
|
||||
}
|
||||
|
||||
function allowsShortPromptEcho(promptText: string): boolean {
|
||||
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
|
||||
}
|
||||
|
||||
function isReliableTypedPrefix(
|
||||
echoedInput: string,
|
||||
typedBuffer: string,
|
||||
options: { allowShortEcho?: boolean } = {},
|
||||
): boolean {
|
||||
if (!typedBuffer.startsWith(echoedInput)) return false;
|
||||
if (
|
||||
options.allowShortEcho &&
|
||||
typedBuffer.trim().length <= 2 &&
|
||||
echoedInput.trim().length >= 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
|
||||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4)
|
||||
);
|
||||
}
|
||||
|
||||
function withTypedUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
...prompt,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function alignThemedDecorationWithPartialEcho(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
|
||||
|
||||
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
|
||||
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!raw.userInput.endsWith(echoedInput)) continue;
|
||||
|
||||
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
|
||||
if (extra.length === 0) continue;
|
||||
const hasReliableThemedDirectoryPrefix =
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
hasExplicitThemedDirectorySpacing(extra) &&
|
||||
typedBuffer.trim().length <= 3 &&
|
||||
echoedInput.trim().length >= 1;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...raw,
|
||||
userInput: extra + typedBuffer,
|
||||
cursorOffset: extra.length + typedBuffer.length,
|
||||
};
|
||||
if (
|
||||
!hasReliableThemedDirectoryPrefix &&
|
||||
!isReliableTypedPrefix(echoedInput, typedBuffer)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) continue;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText: raw.promptText + extra,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -88,15 +621,26 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const lineText = line.translateToString(false);
|
||||
|
||||
// Check for non-prompt patterns (pagers, editors, etc.)
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return NO_PROMPT;
|
||||
if (isNonPromptLine(lineText)) return NO_PROMPT;
|
||||
if (line.isWrapped) {
|
||||
const wrappedPrefix = getWrappedCursorPrefix(term);
|
||||
if (wrappedPrefix && isNonPromptLine(wrappedPrefix)) return NO_PROMPT;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
// Try to find the prompt boundary on the current line
|
||||
const promptEnd = findPromptBoundary(lineText);
|
||||
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
|
||||
// Try to find the prompt boundary on the current line. xterm buffer rows are
|
||||
// padded with blank cells; when the cursor is at the visible row end, scan
|
||||
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
|
||||
// trailing space. If there is command text to the right of the cursor, keep
|
||||
// the full line so "$" / ">" inside mid-line edits are validated against
|
||||
// their real following character.
|
||||
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Use cursor position to determine actual input length — don't trim trailing
|
||||
@@ -125,6 +669,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (promptLine) {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
@@ -139,6 +684,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
|
||||
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
|
||||
const cursorOffset = userInput.length;
|
||||
if (isNonPromptLine(promptText + userInput)) return NO_PROMPT;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
@@ -165,6 +711,56 @@ function isPuaChar(ch: string): boolean {
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
}
|
||||
|
||||
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
|
||||
if (boundary <= 0) return -1;
|
||||
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
|
||||
if (markerIndex <= 0) return false;
|
||||
|
||||
const marker = lineText[markerIndex];
|
||||
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
|
||||
|
||||
const prev = lineText[markerIndex - 1];
|
||||
return !/[\s~:\])}]/.test(prev);
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
|
||||
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
|
||||
}
|
||||
|
||||
function canSupersedeThemedPromptBoundary(
|
||||
lineText: string,
|
||||
previousBoundary: number,
|
||||
markerIndex: number,
|
||||
): boolean {
|
||||
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
|
||||
|
||||
const rawBetween = lineText.slice(previousBoundary, markerIndex);
|
||||
const between = rawBetween.trim();
|
||||
return (
|
||||
between.length === 0 ||
|
||||
isPromptPathDecoration(between) ||
|
||||
isPromptStatusText(between) ||
|
||||
(
|
||||
/^\s/.test(rawBetween) &&
|
||||
isPromptBareDirectoryText(between)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
|
||||
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "›";
|
||||
}
|
||||
|
||||
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
|
||||
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
|
||||
if (markerIndex <= 0) return false;
|
||||
if (lineText[markerIndex - 1] !== " ") return false;
|
||||
return lineText[markerIndex + 1] === " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
|
||||
@@ -193,6 +789,15 @@ function findPromptBoundary(lineText: string): number {
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
if (
|
||||
(ch === ">" || ch === "›") &&
|
||||
lastStandardBoundary >= 0 &&
|
||||
/\s/.test(lineText.slice(0, i).trim()) &&
|
||||
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
|
||||
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Must be followed by a space or end-of-line.
|
||||
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
@@ -252,6 +857,31 @@ function findPromptBoundary(lineText: string): number {
|
||||
// Record this as a candidate boundary. A standard shell prompt terminator
|
||||
// is more reliable than a later Powerline/Nerd Font glyph in command text.
|
||||
const boundary = nextChar === " " ? i + 2 : i + 1;
|
||||
const candidatePromptText = lineText.slice(0, boundary);
|
||||
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
|
||||
continue;
|
||||
}
|
||||
if (isStandard && lastStandardBoundary >= 0) {
|
||||
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
|
||||
lineText,
|
||||
lastStandardBoundary,
|
||||
getBoundaryMarkerIndex(lineText, boundary),
|
||||
);
|
||||
const canSupersedePreviousBoundary =
|
||||
canPromptMarkerSupersedePreviousBoundary(ch) &&
|
||||
(
|
||||
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
|
||||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
|
||||
themedPromptCanSupersede
|
||||
) &&
|
||||
(
|
||||
themedPromptCanSupersede ||
|
||||
isSpecificShellPromptCandidate(candidatePromptText, {
|
||||
allowGreaterThanTerminator: ch === ">" || ch === "›",
|
||||
})
|
||||
);
|
||||
if (!canSupersedePreviousBoundary) continue;
|
||||
}
|
||||
if (isStandard) {
|
||||
lastStandardBoundary = boundary;
|
||||
} else {
|
||||
@@ -291,6 +921,11 @@ export function reconcilePromptWithTypedInput(
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
if (!canReconcilePromptDecoration(prompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return prompt;
|
||||
}
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
@@ -302,6 +937,36 @@ export function reconcilePromptWithTypedInput(
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function reconcilePromptWithExternalCommand(
|
||||
prompt: PromptDetectionResult,
|
||||
command: string,
|
||||
): PromptDetectionResult | null {
|
||||
const typedInput = command.trim();
|
||||
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...prompt,
|
||||
userInput: `${prompt.userInput}${typedInput}`,
|
||||
cursorOffset: prompt.userInput.length + typedInput.length,
|
||||
};
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = syntheticPrompt.userInput.slice(
|
||||
0,
|
||||
syntheticPrompt.userInput.length - typedInput.length,
|
||||
);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
@@ -312,13 +977,11 @@ export function reconcilePromptWithTypedInput(
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable AND the raw detected prompt still looks like the same shell
|
||||
* line. When the raw buffer has either over-captured prompt chrome
|
||||
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
|
||||
* shell echo/render is lagging behind local keystrokes
|
||||
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
|
||||
* Otherwise the buffer is ignored and the raw detector result passes
|
||||
* through.
|
||||
* reliable and it can be reconciled with the live line. Exact raw
|
||||
* matches are safe, over-captured prompt chrome can be moved back into
|
||||
* promptText, and no-space prompts can be inferred from the cursor line
|
||||
* when the inferred prompt still looks like a shell prompt. Otherwise
|
||||
* the buffer is ignored and the raw detector result passes through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
@@ -327,38 +990,40 @@ export function getAlignedPrompt(
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
|
||||
if (!typedReliable || typedBuffer.length === 0) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
return {
|
||||
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
|
||||
return {
|
||||
prompt: replacePromptUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const cursorLinePrefix = getCursorLinePrefix(term);
|
||||
if (cursorLinePrefix?.endsWith(typedBuffer)) {
|
||||
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
|
||||
if (promptText.length > 0) {
|
||||
|
||||
if (raw.isAtPrompt) {
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
|
||||
if (prompt === raw) return { prompt: raw, alignedTyped: null };
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
prompt,
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
|
||||
if (themedDecorationAlignment) return themedDecorationAlignment;
|
||||
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
|
||||
return {
|
||||
prompt: withTypedUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cursorPrefixCandidates = [
|
||||
getWrappedCursorPrefix(term),
|
||||
getCursorLinePrefix(term),
|
||||
];
|
||||
for (const cursorPrefix of cursorPrefixCandidates) {
|
||||
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
|
||||
if (aligned) return aligned;
|
||||
}
|
||||
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
49
components/terminal/autocomplete/snippetCompleter.ts
Normal file
49
components/terminal/autocomplete/snippetCompleter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
|
||||
* when the user is typing the command name. Matches against the snippet label
|
||||
* and the first line of its command (case-insensitive; prefix matches rank
|
||||
* above substring matches). Each suggestion carries the full Snippet so the
|
||||
* accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function appliesToHost(snippet: Snippet, hostId?: string): boolean {
|
||||
if (!snippet.targets || snippet.targets.length === 0) return true;
|
||||
return hostId !== undefined && snippet.targets.includes(hostId);
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!appliesToHost(snippet, options.hostId)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
const labelPrefix = label.startsWith(needle);
|
||||
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
|
||||
if (!matches) continue;
|
||||
|
||||
out.push({
|
||||
text: snippet.label,
|
||||
displayText: snippet.label,
|
||||
description: snippet.command,
|
||||
source: "snippet",
|
||||
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out;
|
||||
}
|
||||
@@ -11,14 +11,21 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { GhostTextAddon } from "./GhostTextAddon";
|
||||
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
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";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -41,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: [],
|
||||
@@ -94,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 {
|
||||
@@ -107,10 +130,100 @@ export interface TerminalAutocompleteHandle {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
function hasStandardShellPromptTerminator(promptText: string): boolean {
|
||||
return /[$#%>]$/.test(promptText.trimEnd());
|
||||
}
|
||||
|
||||
function isSingleThemedPromptTerminator(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length !== 1) return false;
|
||||
const code = trimmed.charCodeAt(0);
|
||||
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
|
||||
}
|
||||
|
||||
function isThemedPromptPathToken(token: string): boolean {
|
||||
return (
|
||||
token === "~" ||
|
||||
token.startsWith("~/") ||
|
||||
token.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token) ||
|
||||
token.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
|
||||
const hasThemedPromptMarker =
|
||||
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
|
||||
Array.from(prompt.promptText).some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
});
|
||||
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
|
||||
return false;
|
||||
}
|
||||
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
|
||||
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
|
||||
return (
|
||||
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
|
||||
/\S+\s+\S/.test(prompt.userInput)
|
||||
);
|
||||
}
|
||||
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
|
||||
}
|
||||
|
||||
export function getCommandToRecordOnEnter(
|
||||
livePrompt: PromptDetectionResult,
|
||||
alignedTyped: string | null,
|
||||
typedBuffer: string,
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
|
||||
if (reliableTypedCommand) {
|
||||
const reconciledPrompt = reconcilePromptWithExternalCommand(
|
||||
livePrompt,
|
||||
reliableTypedCommand,
|
||||
);
|
||||
if (reconciledPrompt) return reliableTypedCommand;
|
||||
}
|
||||
|
||||
const liveCommand = livePrompt.userInput.trim();
|
||||
if (!liveCommand && reliableTypedCommand) {
|
||||
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
|
||||
? null
|
||||
: reliableTypedCommand;
|
||||
}
|
||||
if (!liveCommand) return null;
|
||||
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
const liveInputMayIncludePromptDecoration =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
liveCommand !== typedBuffer.trim() &&
|
||||
liveCommand.endsWith(typedBuffer.trim());
|
||||
if (liveInputMayIncludePromptDecoration) return null;
|
||||
|
||||
const liveInputMayBeLagging =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
typedBuffer.length > livePrompt.userInput.length &&
|
||||
typedBuffer.startsWith(livePrompt.userInput);
|
||||
if (liveInputMayBeLagging) return null;
|
||||
|
||||
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -132,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);
|
||||
@@ -158,6 +275,10 @@ export function useTerminalAutocomplete(
|
||||
const fetchVersionRef = useRef(0);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -261,6 +382,10 @@ export function useTerminalAutocomplete(
|
||||
* Clear popup/ghost state. Skips re-render if already empty.
|
||||
*/
|
||||
const clearState = useCallback(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
ghostAddonRef.current?.hide();
|
||||
// Bump version to invalidate any in-flight async completions
|
||||
fetchVersionRef.current++;
|
||||
@@ -441,6 +566,41 @@ export function useTerminalAutocomplete(
|
||||
});
|
||||
}, [termRef]);
|
||||
|
||||
/**
|
||||
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
|
||||
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
|
||||
*/
|
||||
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term, typedInputBufferRef.current, typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const parsed = parseCommandLine(prompt.userInput);
|
||||
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
|
||||
+ (parsed.wordIndex > 0 ? " " : "");
|
||||
const currentToken = parsed.currentWord;
|
||||
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
|
||||
? currentToken[0] : "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name : shellEscape(entry.name);
|
||||
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Handle selecting a file/directory from any sub-dir panel.
|
||||
* Builds the full path from the panel stack and replaces the current input. */
|
||||
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
|
||||
@@ -505,6 +665,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;
|
||||
|
||||
@@ -543,6 +712,7 @@ export function useTerminalAutocomplete(
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
cwd,
|
||||
snippets: snippetsRef.current,
|
||||
});
|
||||
|
||||
if (disposedRef.current || version !== fetchVersionRef.current) return;
|
||||
@@ -560,7 +730,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);
|
||||
@@ -571,9 +742,14 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
if (version !== fetchVersionRef.current) return prev;
|
||||
|
||||
const nextState: AutocompleteState = {
|
||||
suggestions: completions,
|
||||
selectedIndex: -1,
|
||||
@@ -643,29 +819,21 @@ export function useTerminalAutocomplete(
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const typedBuffer = typedInputBufferRef.current;
|
||||
const typedBufferReliable = typedBufferReliableRef.current;
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (livePrompt.isAtPrompt) {
|
||||
// alignedTyped is only non-null when the buffer is reliable
|
||||
// AND matches the live line's tail — that single signal
|
||||
// covers both the robbyrussell "~ " case (#806) and the
|
||||
// stale-buffer cases from out-of-band pastes / history
|
||||
// recall (#814 P1/P2). When it's null we fall back to the
|
||||
// reconciled livePrompt.userInput, which for paste-bypass
|
||||
// scenarios lands on pre-PR behavior (no regression).
|
||||
if (alignedTyped && alignedTyped.trim()) {
|
||||
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
|
||||
} else if (livePrompt.userInput.trim()) {
|
||||
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
|
||||
// Only fall back to the cached prompt when we have no live
|
||||
// reading at all — guards against recording during interactive
|
||||
// prompts where detectPrompt correctly bails out.
|
||||
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
const commandToRecord = getCommandToRecordOnEnter(
|
||||
livePrompt,
|
||||
alignedTyped,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (commandToRecord) {
|
||||
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
@@ -789,6 +957,10 @@ export function useTerminalAutocomplete(
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
@@ -827,10 +999,12 @@ export function useTerminalAutocomplete(
|
||||
if (isFastTyping) {
|
||||
// Still debounce, but with a longer delay to wait for typing to pause
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
fetchSuggestions();
|
||||
}, settingsRef.current.debounceMs * 3);
|
||||
} else {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
fetchSuggestions();
|
||||
}, settingsRef.current.debounceMs);
|
||||
}
|
||||
@@ -968,10 +1142,11 @@ export function useTerminalAutocomplete(
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
@@ -1000,8 +1175,10 @@ export function useTerminalAutocomplete(
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
@@ -1057,39 +1234,44 @@ export function useTerminalAutocomplete(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation
|
||||
if (e.key === "ArrowUp") {
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
|
||||
renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
if (s.selectedIndex >= 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
insertSuggestion(selected, true);
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,8 +1280,12 @@ export function useTerminalAutocomplete(
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1109,6 +1295,59 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
*/
|
||||
const renderPreviewSelection = useCallback((index: number) => {
|
||||
const s = stateRef.current;
|
||||
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 =
|
||||
selected && selected.source !== "snippet" ? selected.text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput,
|
||||
candidate,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = candidate;
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
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.
|
||||
@@ -1181,9 +1420,13 @@ export function useTerminalAutocomplete(
|
||||
*/
|
||||
const selectSuggestion = useCallback(
|
||||
(suggestion: CompletionSuggestion) => {
|
||||
if (suggestion.source === "snippet" && suggestion.snippet) {
|
||||
acceptSnippet(suggestion.snippet);
|
||||
return;
|
||||
}
|
||||
insertSuggestion(suggestion, false);
|
||||
},
|
||||
[insertSuggestion],
|
||||
[insertSuggestion, acceptSnippet],
|
||||
);
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
|
||||
19
components/terminal/completionEngineSnippets.test.ts
Normal file
19
components/terminal/completionEngineSnippets.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getCompletions } from "./autocomplete/completionEngine";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
|
||||
|
||||
test("getCompletions includes snippet suggestions at the command position", async () => {
|
||||
const out = await getCompletions("dep", { snippets: [deploySnippet] });
|
||||
const snip = out.find((s) => s.source === "snippet");
|
||||
assert.ok(snip, "expected a snippet suggestion");
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets past the command position", async () => {
|
||||
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
25
components/terminal/completionGate.test.ts
Normal file
25
components/terminal/completionGate.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
test("queries completions when the popup menu is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("queries completions when ghost text is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips completion work when both popup and ghost text are off", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
98
components/terminal/connectionLogBuffer.test.ts
Normal file
98
components/terminal/connectionLogBuffer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
|
||||
|
||||
test("concatenates appended chunks while under the cap", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("foo");
|
||||
buf.append("bar");
|
||||
buf.append("baz");
|
||||
assert.equal(buf.toString(), "foobarbaz");
|
||||
});
|
||||
|
||||
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
|
||||
const max = 10;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
assert.equal(buf.toString().length, max);
|
||||
});
|
||||
|
||||
test("trims a single chunk larger than the cap to its last maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(5);
|
||||
buf.append("0123456789");
|
||||
assert.equal(buf.toString(), "56789");
|
||||
});
|
||||
|
||||
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(6);
|
||||
buf.append("abcde"); // 5
|
||||
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
|
||||
assert.equal(buf.toString(), "efghij");
|
||||
});
|
||||
|
||||
test("stays correct across many small appends (ring semantics)", () => {
|
||||
const max = 50;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const chunk = `x${i}-`;
|
||||
buf.append(chunk);
|
||||
naive += chunk;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
});
|
||||
|
||||
test("reset clears the buffer", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("hello");
|
||||
buf.reset();
|
||||
assert.equal(buf.toString(), "");
|
||||
buf.append("world");
|
||||
assert.equal(buf.toString(), "world");
|
||||
});
|
||||
|
||||
test("ignores empty appends", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("a");
|
||||
buf.append("");
|
||||
buf.append("b");
|
||||
assert.equal(buf.toString(), "ab");
|
||||
});
|
||||
|
||||
test("keeps the segment count bounded across many tiny appends", () => {
|
||||
// The whole point of the rewrite: trimming must not walk one array entry
|
||||
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
|
||||
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
|
||||
// single-char appends arrive once it's at capacity.
|
||||
const maxChars = 100;
|
||||
const blockSize = 10;
|
||||
const buf = createConnectionLogBuffer(maxChars, blockSize);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
buf.append("x");
|
||||
naive += "x";
|
||||
}
|
||||
assert.ok(
|
||||
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
|
||||
`segmentCount ${buf.segmentCount()} exceeded the bound`,
|
||||
);
|
||||
assert.equal(buf.toString(), naive.slice(-maxChars));
|
||||
});
|
||||
|
||||
test("seals and trims whole blocks with a small blockSize", () => {
|
||||
const buf = createConnectionLogBuffer(10, 4);
|
||||
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
|
||||
});
|
||||
94
components/terminal/connectionLogBuffer.ts
Normal file
94
components/terminal/connectionLogBuffer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A bounded, append-only text buffer that retains only the last `maxChars`
|
||||
* characters — the connection log used for diagnostics/replay.
|
||||
*
|
||||
* The naive implementation (`log += chunk; if (log.length > max) log =
|
||||
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
|
||||
* cap is reached — on the render thread, for every output chunk including each
|
||||
* echoed keystroke.
|
||||
*
|
||||
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
|
||||
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
|
||||
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
|
||||
* the oldest data therefore only ever drops/slices a handful of blocks — never
|
||||
* one array element per append, which would make trim O(number of appends) and
|
||||
* defeat the purpose. Append is amortized O(chunk); the full string is
|
||||
* materialized only on `toString()` (called rarely, on finalize).
|
||||
*/
|
||||
export interface ConnectionLogBuffer {
|
||||
append(chunk: string): void;
|
||||
toString(): string;
|
||||
reset(): void;
|
||||
/**
|
||||
* Number of internal string segments currently retained. Exposed for tests
|
||||
* to assert the bounded-memory / bounded-trim property.
|
||||
*/
|
||||
segmentCount(): number;
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
export function createConnectionLogBuffer(
|
||||
maxChars: number,
|
||||
blockSize: number = DEFAULT_BLOCK_SIZE,
|
||||
): ConnectionLogBuffer {
|
||||
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
|
||||
let tail = ""; // open block currently being filled (newest data)
|
||||
let total = 0; // total retained length across blocks + tail
|
||||
|
||||
const trim = () => {
|
||||
let overflow = total - maxChars;
|
||||
if (overflow <= 0) return;
|
||||
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
|
||||
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
|
||||
while (overflow > 0 && blocks.length > 0) {
|
||||
const head = blocks[0];
|
||||
if (head.length <= overflow) {
|
||||
blocks.shift();
|
||||
total -= head.length;
|
||||
overflow -= head.length;
|
||||
} else {
|
||||
blocks[0] = head.slice(overflow);
|
||||
total -= overflow;
|
||||
overflow = 0;
|
||||
}
|
||||
}
|
||||
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
|
||||
// maxChars); keep its last `maxChars` characters.
|
||||
if (overflow > 0) {
|
||||
tail = tail.slice(overflow);
|
||||
total -= overflow;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
// A single chunk at/over the cap can only contribute its own tail.
|
||||
if (chunk.length >= maxChars) {
|
||||
blocks = [];
|
||||
tail = chunk.slice(chunk.length - maxChars);
|
||||
total = tail.length;
|
||||
return;
|
||||
}
|
||||
tail += chunk;
|
||||
total += chunk.length;
|
||||
if (tail.length >= blockSize) {
|
||||
blocks.push(tail);
|
||||
tail = "";
|
||||
}
|
||||
if (total > maxChars) trim();
|
||||
},
|
||||
toString(): string {
|
||||
return blocks.length > 0 ? blocks.join("") + tail : tail;
|
||||
},
|
||||
reset(): void {
|
||||
blocks = [];
|
||||
tail = "";
|
||||
total = 0;
|
||||
},
|
||||
segmentCount(): number {
|
||||
return blocks.length + (tail.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
45
components/terminal/ghostTextConsistency.test.ts
Normal file
45
components/terminal/ghostTextConsistency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
|
||||
|
||||
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
|
||||
});
|
||||
|
||||
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
|
||||
// Tracked is one char behind what the device actually shows.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
|
||||
});
|
||||
|
||||
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
|
||||
// The tracked input hasn't been fully echoed yet — reality being behind
|
||||
// never corrupts, so the ghost must stay.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
|
||||
});
|
||||
|
||||
test("ignores trailing whitespace after the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
|
||||
});
|
||||
|
||||
test("hides when untracked non-space input follows the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
|
||||
});
|
||||
|
||||
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
|
||||
// Prompt contains 'int'; the real typed 'int' is the one at the end.
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
|
||||
});
|
||||
|
||||
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
|
||||
});
|
||||
|
||||
test("skips single-character input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
|
||||
});
|
||||
|
||||
test("returns false when the tracked input isn't on the line yet (latency)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
}
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
|
||||
45
components/terminal/livePreviewSequence.test.ts
Normal file
45
components/terminal/livePreviewSequence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
|
||||
|
||||
test("appends only the tail when the candidate continues the current line", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
|
||||
"cker",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty when the line already equals the candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
|
||||
"\x15df",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears when switching to a shorter prefix candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
|
||||
"\x15docker",
|
||||
);
|
||||
});
|
||||
|
||||
test("reverting to the typed baseline clears then rewrites the baseline", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
|
||||
"\x15do",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
|
||||
"\b\b\bxy",
|
||||
);
|
||||
});
|
||||
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
|
||||
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
|
||||
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
|
||||
// flag was read from settings but only ever wired to the mouse alt-click
|
||||
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
|
||||
|
||||
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(true), {
|
||||
macOptionIsMeta: true,
|
||||
altClickMovesCursor: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(false), {
|
||||
macOptionIsMeta: false,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
});
|
||||
20
components/terminal/runtime/altKeyOptions.ts
Normal file
20
components/terminal/runtime/altKeyOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface TerminalAltKeyOptions {
|
||||
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
|
||||
macOptionIsMeta: boolean;
|
||||
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
|
||||
altClickMovesCursor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the user's "Use Option as Meta key" setting to xterm.js options.
|
||||
*
|
||||
* Kept in one place so terminal init (createXTermRuntime) and the live settings
|
||||
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
|
||||
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
|
||||
*/
|
||||
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
|
||||
return {
|
||||
macOptionIsMeta: altAsMeta,
|
||||
altClickMovesCursor: !altAsMeta,
|
||||
};
|
||||
}
|
||||
@@ -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,9 +303,11 @@ 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 ?? true;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
|
||||
if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) {
|
||||
ctx.promptLineBreakStateRef.current.pendingCommand = false;
|
||||
ctx.promptLineBreakStateRef.current.suppressNextPromptCache = 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,8 +416,32 @@ 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,
|
||||
id: string,
|
||||
onSettled?: () => void,
|
||||
): (() => void) | undefined => {
|
||||
@@ -385,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);
|
||||
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 (
|
||||
@@ -639,6 +745,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
identityFilePaths: jumpIdentityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: jumpHost.algorithms,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -794,6 +903,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
x11Forwarding: ctx.host.x11Forwarding,
|
||||
x11Display: ctx.terminalSettings?.x11Display,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
|
||||
algorithmOverrides: ctx.host.algorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
@@ -877,7 +988,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
|
||||
// Run OS detection only after successful connection. Mint a fresh
|
||||
// token for this specific connection attempt and register it as
|
||||
@@ -991,7 +1102,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.sessionId,
|
||||
() => {
|
||||
disposeAutoLoginListener();
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = undefined;
|
||||
disposeAutoLoginCancelListener();
|
||||
});
|
||||
@@ -1158,7 +1269,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
@@ -1203,6 +1314,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) {
|
||||
|
||||
@@ -4,6 +4,50 @@ import assert from "node:assert/strict";
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||||
|
||||
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("command execution arms prompt line break even without command history callback", () => {
|
||||
const promptState = createPromptLineBreakState();
|
||||
const commandBufferRef = { current: "echo ok" };
|
||||
@@ -12,8 +56,6 @@ test("command execution arms prompt line break even without command history call
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
@@ -23,3 +65,332 @@ test("command execution arms prompt line break even without command history call
|
||||
assert.equal(commandBufferRef.current, "");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution caches the current prompt instead of prompt-like command text", () => {
|
||||
const promptState = createPromptLineBreakState();
|
||||
const commandBufferRef = { current: "echo > out" };
|
||||
|
||||
recordTerminalCommandExecution("echo > out", {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
}, createFakeTerm("$ echo > out") as never);
|
||||
|
||||
assert.equal(promptState.lastPromptText, "$ ");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution does not write interactive program input to shell history", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, "", lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record interactive input before echo appears", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "rs.status()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record wrapped interactive program input", () => {
|
||||
const cases = [
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
||||
const commands = ["deploy", "exit", "help", "show dbs"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help" },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs" },
|
||||
{ rows: ["admin> ex", "it"], command: "exit" },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records short commands when standard prompt echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls" },
|
||||
{ lineText: "prod> l", command: "ls" },
|
||||
{ lineText: "prod.web> l", command: "ls" },
|
||||
{ lineText: "user@host:~$ l", command: "ls" },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls" },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls" },
|
||||
{ lineText: "➜ git l", command: "ls" },
|
||||
{ lineText: "➜ git np", command: "npm" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from themed bare directory prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, promptText, lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution still records host-style greater-than prompts", () => {
|
||||
const prompts = [
|
||||
"prod-web> ",
|
||||
"prod> ",
|
||||
"prod.web> ",
|
||||
"server> ",
|
||||
"staging> ",
|
||||
"webdb> ",
|
||||
"prod.db> ",
|
||||
];
|
||||
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from host-style greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,8 +43,12 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
@@ -78,6 +82,13 @@ export type XTermRuntime = {
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
/**
|
||||
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
|
||||
* next frame. No-op when the DOM renderer is active. Used to recover from the
|
||||
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
|
||||
* fall into after font changes or device pixel ratio changes.
|
||||
*/
|
||||
clearTextureAtlas: () => void;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -157,6 +168,15 @@ const detectPlatform = (): XTermPlatform => {
|
||||
return "darwin";
|
||||
};
|
||||
|
||||
const csiParamsInclude = (
|
||||
params: readonly (number | number[])[],
|
||||
target: number,
|
||||
): boolean => params.some((param) => (
|
||||
Array.isArray(param)
|
||||
? param.includes(target)
|
||||
: param === target
|
||||
));
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
@@ -276,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
smoothScrollDuration,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
...terminalAltKeyOptions(altIsMeta),
|
||||
wordSeparator,
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
@@ -376,6 +396,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
|
||||
// (claude code / gemini cli / opencode and other full-screen agents), font
|
||||
// changes, and device pixel ratio changes can leave that atlas in a corrupted
|
||||
// state that persists for the life of the terminal — the "garbled / 花屏"
|
||||
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
|
||||
// the atlas forces glyphs to re-rasterize at the correct scale on the next
|
||||
// frame. No-op for the DOM renderer.
|
||||
const clearWebglTextureAtlas = () => {
|
||||
if (!webglAddon) return;
|
||||
try {
|
||||
webglAddon.clearTextureAtlas();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] clearTextureAtlas failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Recover the renderer when the device pixel ratio changes (moving the window
|
||||
// between monitors with different DPI, or changing OS display scaling — a
|
||||
// common Windows trigger). matchMedia change does not fire a normal resize, so
|
||||
// this is needed in addition to the resize handling below.
|
||||
let stopDprWatch: () => void = () => {};
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
) {
|
||||
stopDprWatch = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: () => window.devicePixelRatio || 1,
|
||||
matchMedia: (query) => window.matchMedia(query),
|
||||
onChange: () => {
|
||||
clearWebglTextureAtlas();
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||||
let shouldOpen = false;
|
||||
@@ -514,7 +573,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.onAutocompleteInput?.(snippetData);
|
||||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||||
if (!snippet.noAutoRun) {
|
||||
recordTerminalCommandExecution(snippet.command, ctx);
|
||||
recordTerminalCommandExecution(snippet.command, ctx, term);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -599,6 +658,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
|
||||
// #826). After kitty mode so apps using the kitty protocol keep their own
|
||||
// arrow encoding; read live so the toggle applies without reconnecting.
|
||||
const wordJumpSequence = optionArrowWordJumpSequence(
|
||||
e,
|
||||
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
|
||||
isMacPlatform(),
|
||||
);
|
||||
if (wordJumpSequence) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.onAutocompleteInput?.(wordJumpSequence);
|
||||
ctx.terminalBackend.writeToSession(id, wordJumpSequence);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
|
||||
}
|
||||
scrollToBottomAfterInput(wordJumpSequence);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -687,7 +769,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
if (ctx.statusRef.current === "connected") {
|
||||
if (data === "\r" || data === "\n") {
|
||||
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx);
|
||||
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x03") {
|
||||
@@ -721,6 +803,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
|
||||
if (csiParamsInclude(params, 6)) {
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cursorPositionReportRequestDisposables = [
|
||||
term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
|
||||
term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
|
||||
];
|
||||
|
||||
const writeKittyKeyboardReply = (payload: string) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
@@ -836,6 +930,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
@@ -854,10 +951,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
clearTextureAtlas: clearWebglTextureAtlas,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
kittyKeyboardDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
|
||||
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
|
||||
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
|
||||
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
|
||||
// readline/zle does backward-word / forward-word out of the box (Termius-style).
|
||||
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
|
||||
|
||||
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
|
||||
});
|
||||
|
||||
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
|
||||
});
|
||||
|
||||
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
|
||||
});
|
||||
|
||||
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
|
||||
});
|
||||
|
||||
test("no Option held → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
|
||||
});
|
||||
|
||||
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
|
||||
});
|
||||
|
||||
test("non-arrow keys → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
|
||||
});
|
||||
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface OptionArrowKeyEvent {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Option+←/→ word-jump (discussion #826).
|
||||
*
|
||||
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
|
||||
* readline/zle does backward-word / forward-word without per-host bindkey setup.
|
||||
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
|
||||
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
|
||||
* default ^[[1;3D / ^[[1;3C is left untouched.
|
||||
*
|
||||
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
|
||||
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
|
||||
* devices (discussion #826 review).
|
||||
*/
|
||||
export function optionArrowWordJumpSequence(
|
||||
e: OptionArrowKeyEvent,
|
||||
enabled: boolean,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!enabled || !isMac) return null;
|
||||
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
|
||||
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
|
||||
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
|
||||
return null;
|
||||
}
|
||||
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createOutputFlowController } from "./outputFlowController.ts";
|
||||
|
||||
function make(high = 100, low = 30) {
|
||||
const events: string[] = [];
|
||||
const controller = createOutputFlowController({
|
||||
highWaterMark: high,
|
||||
lowWaterMark: low,
|
||||
onPause: () => events.push("pause"),
|
||||
onResume: () => events.push("resume"),
|
||||
});
|
||||
return { controller, events };
|
||||
}
|
||||
|
||||
test("does not pause while below the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(50);
|
||||
controller.received(49); // 99 < 100
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("pauses once when crossing the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(60);
|
||||
controller.received(60); // 120 >= 100 -> pause
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
// Further received while already paused must not re-fire pause.
|
||||
controller.received(100);
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
});
|
||||
|
||||
test("resumes once when draining to at/below the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(50); // 70 still > 30, no resume
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
controller.written(50); // 20 <= 30 -> resume
|
||||
assert.deepEqual(events, ["pause", "resume"]);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("does not resume when still above the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(80); // 40 > 30
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
});
|
||||
|
||||
test("never lets pending go negative", () => {
|
||||
const { controller } = make(100, 30);
|
||||
controller.received(10);
|
||||
controller.written(50); // over-written
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
});
|
||||
|
||||
test("supports repeated pause/resume cycles", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(120); // resume (0 <= 30)
|
||||
controller.received(120); // pause again
|
||||
controller.written(120); // resume again
|
||||
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
|
||||
});
|
||||
|
||||
test("reset clears state without firing callbacks", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.reset();
|
||||
assert.equal(controller.isPaused(), false);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, ["pause"]); // reset itself is silent
|
||||
// A fresh cycle works after reset.
|
||||
controller.received(120);
|
||||
assert.deepEqual(events, ["pause", "pause"]);
|
||||
});
|
||||
|
||||
test("ignores non-positive amounts", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(0);
|
||||
controller.written(0);
|
||||
controller.received(-5);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
73
components/terminal/runtime/outputFlowController.ts
Normal file
73
components/terminal/runtime/outputFlowController.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Watermark-based flow control for terminal output.
|
||||
*
|
||||
* SSH/PTY output has no back-pressure by default: the source streams as fast as
|
||||
* it can, the main process forwards it over IPC, and the renderer queues every
|
||||
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
|
||||
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
|
||||
* buffer grow without bound — memory climbs and the whole UI, typing included,
|
||||
* janks.
|
||||
*
|
||||
* This tracks bytes that have been received but not yet acknowledged by xterm's
|
||||
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
|
||||
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
|
||||
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
|
||||
* backlog hovers near zero, so this never engages.
|
||||
*/
|
||||
export interface OutputFlowController {
|
||||
/** Account bytes handed to xterm (call when a chunk is received). */
|
||||
received(bytes: number): void;
|
||||
/** Account bytes whose xterm write callback has fired. */
|
||||
written(bytes: number): void;
|
||||
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
|
||||
reset(): void;
|
||||
pendingBytes(): number;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
export interface OutputFlowControllerOptions {
|
||||
highWaterMark: number;
|
||||
lowWaterMark: number;
|
||||
/** Asked to pause the source when the backlog crosses the high watermark. */
|
||||
onPause: () => void;
|
||||
/** Asked to resume the source when the backlog drains to the low watermark. */
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
export function createOutputFlowController(
|
||||
options: OutputFlowControllerOptions,
|
||||
): OutputFlowController {
|
||||
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
|
||||
let pending = 0;
|
||||
let paused = false;
|
||||
|
||||
return {
|
||||
received(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending += bytes;
|
||||
if (!paused && pending >= highWaterMark) {
|
||||
paused = true;
|
||||
onPause();
|
||||
}
|
||||
},
|
||||
written(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending -= bytes;
|
||||
if (pending < 0) pending = 0;
|
||||
if (paused && pending <= lowWaterMark) {
|
||||
paused = false;
|
||||
onResume();
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
pending = 0;
|
||||
paused = false;
|
||||
},
|
||||
pendingBytes(): number {
|
||||
return pending;
|
||||
},
|
||||
isPaused(): boolean {
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
insertPromptLineBreakBeforePrompt,
|
||||
markPromptLineBreakCommandPending,
|
||||
prepareTerminalDataForPromptLineBreak,
|
||||
syncPromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
@@ -29,6 +30,29 @@ function createFakeTerm(lineText = "", cursorX = lineText.length) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("does not insert before prompt-like suffixes in a larger output chunk", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0),
|
||||
@@ -71,6 +95,56 @@ test("does not insert for output chunks that only end with the cached prompt tex
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before an ambiguous prompt suffix inside output", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("world$ ", "$ ", 5),
|
||||
"world$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before prompt-like output after a line break", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("\r\nhello$ ", "$ ", 0),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct root prompt in the same output chunk", () => {
|
||||
const prompt = "[root@iZwz9ftrhzy4b3hduolf6yZ ~]# ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct conda prompt in the same output chunk", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct no-space root prompt in the same output chunk", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before an already separated distinct prompt", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail\r\n${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not refresh cached prompt from output that only ends with the prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
@@ -90,10 +164,724 @@ test("does not refresh cached prompt from output that only ends with the prompt
|
||||
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, false);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("keeps waiting for the real prompt after an output suffix matches the prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"total $ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"total $ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("total $ ", 8) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps waiting after prompt-like output on a fresh line", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"\r\nhello$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("hello$ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("hello$ ", 7) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepares a same-chunk cat output break for a distinct prompt", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "(base) rynn@aiserver:~$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"without trailing newline(base) rynn@aiserver:~$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"without trailing newline\r\n(base) rynn@aiserver:~$ ",
|
||||
);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt from typed command alignment", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command.slice(0, -1)}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags by a word", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}printf `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a longer command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags mid-word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git st`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags near completion", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ git statu") as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags after a word boundary", () => {
|
||||
const cases = ["$ git ", "$ git st"];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a standard prompt when short command echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls", promptText: "prod-web> " },
|
||||
{ lineText: "prod> l", command: "ls", promptText: "prod> " },
|
||||
{ lineText: "prod.web> l", command: "ls", promptText: "prod.web> " },
|
||||
{ lineText: "user@host:~$ l", command: "ls", promptText: "user@host:~$ " },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls", promptText: "[user@host ~]$ " },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls", promptText: "➜ netcatty $ " },
|
||||
{ lineText: "➜ git l", command: "ls", promptText: "➜ git " },
|
||||
{ lineText: "➜ git np", command: "npm", promptText: "➜ git " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText = "$ " } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "ls ", command: "ls -la" },
|
||||
{ echoedInput: "cd ", command: "cd /tmp" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by one character", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "l", command: "ls" },
|
||||
{ echoedInput: "c", command: "cd" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ ls") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "ftp> ls", command: "ls" },
|
||||
{ lineText: "ghci> :t map", command: ":t map" },
|
||||
{ lineText: "node> .help", command: ".help" },
|
||||
{ lineText: "mongo> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "cqlsh> select * from users", command: "select * from users" },
|
||||
{ lineText: "hive> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql> select 1", command: "select 1" },
|
||||
{ lineText: "jshell> /help", command: "/help" },
|
||||
{ lineText: " ...> System.out.println(1)", command: "System.out.println(1)" },
|
||||
{ lineText: "ksql> select 1", command: "select 1" },
|
||||
{ lineText: "trino> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "presto> show catalogs", command: "show catalogs" },
|
||||
{ lineText: "presto:default> show tables", command: "show tables" },
|
||||
{ lineText: "duckdb> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql (default)> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "test> 1 + 1", command: "1 + 1" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache wrapped common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["sftp> get very-long-", "remote-file"], command: "get very-long-remote-file" },
|
||||
{ rows: ["node> console.", "log('ok')"], command: "console.log('ok')" },
|
||||
{ rows: ["mongo> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["cqlsh> select *", " from users"], command: "select * from users" },
|
||||
{ rows: ["jshell> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: [" ...> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: ["trino> select", " 1"], command: "select 1" },
|
||||
{ rows: ["trino:tpch> select", " 1"], command: "select 1" },
|
||||
{ rows: ["duckdb> select", " 1"], command: "select 1" },
|
||||
{ rows: ["cqlsh:cycling> select *", " from cyclist"], command: "select * from cyclist" },
|
||||
{ rows: ["hive (default)> select", " 1"], command: "select 1" },
|
||||
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], command: "select 1" },
|
||||
{ rows: ["test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
{ rows: ["rs0:PRIMARY> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], command: "const x = 1" },
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 primary test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> print", "(1)"], command: "print(1)" },
|
||||
{ rows: ["test> 1 ", "+ 1"], command: "1 + 1" },
|
||||
{ rows: ["admin@localhost:27017> db.", "stats()"], command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help", promptText: "test> " },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs", promptText: "test> " },
|
||||
{ rows: ["admin> ex", "it"], command: "exit", promptText: "admin> " },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy", promptText: "local> " },
|
||||
];
|
||||
|
||||
for (const { rows, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live command suffix as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ echo sudo") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache host prompt command symbols as prompt text", () => {
|
||||
const prompt = "user@host:~$ ";
|
||||
const cases = [
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a themed prompt live command suffix as prompt text", () => {
|
||||
for (const lineText of [
|
||||
"➜ ~ echo sudo",
|
||||
"➜ echo sudo",
|
||||
"➜ make sudo",
|
||||
"➜ docker sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ~ echo # sudo",
|
||||
]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations from typed command alignment", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~/repo do", command: "do", promptText: "➜ ~/repo " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ + ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ + ",
|
||||
},
|
||||
{ lineText: "➜ netcatty ✗ $ ls", command: "ls", promptText: "➜ netcatty ✗ $ " },
|
||||
{ lineText: "➜ netcatty $ ls", command: "ls", promptText: "➜ netcatty $ " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations when command echo lags", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~ git ", command: "git status", promptText: "➜ ~ " },
|
||||
{ lineText: "➜ ~ git st", command: "git status", promptText: "➜ ~ " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git ",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git st",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed bare directory prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache interactive prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("clears an old cached prompt when a direct send is interactive", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "rs0 [direct: primary] reporting> ";
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("rs0 [direct: primary] reporting> ") as never,
|
||||
"db.stats()",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches host-style greater-than prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, lineText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live path suffix as prompt text", () => {
|
||||
for (const lineText of ["$ cd ~/sudo", "$ cat > sudo", "$ echo path#sudo"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command from a standard prompt echo prefix", () => {
|
||||
for (const lineText of ["$ s", "$ su", "$ sud"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache partial stale commands after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
for (const lineText of [`${prompt}s`, `${prompt}sud`]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache stale command suffixes after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
`${prompt}cat > sudo`,
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo path#sudo`,
|
||||
`${prompt}> sudo`,
|
||||
`${prompt}# sudo`,
|
||||
`${prompt}% sudo`,
|
||||
`${prompt}$ sudo`,
|
||||
];
|
||||
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs prompts that contain prompt-like symbols", () => {
|
||||
const prompts = [
|
||||
"user@host ~/foo# bar $ ",
|
||||
"user@host ~/foo# git $ ",
|
||||
"user@host ~/foo#git $ ",
|
||||
"root@host ~/foo# bar # ",
|
||||
"root@host ~/foo#bar # ",
|
||||
"fish@host ~/foo# bar % ",
|
||||
"fish@host ~/foo%bar % ",
|
||||
"user@host:~/foo# bar $ ",
|
||||
"user@host ~/repo # $ ",
|
||||
"➜ ~ $ ",
|
||||
"user@host ~/foo% bar $ ",
|
||||
"user@host ~/foo> bar $ ",
|
||||
"user@host ~/foo# bar> ",
|
||||
"user@host ~/foo# bar› ",
|
||||
"user@host ~/foo#bar> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(prompt) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, prompt);
|
||||
assert.equal(state.pendingCommand, false, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs a no-space root prompt without xterm row padding", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(`${prompt} `, prompt.length) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, false);
|
||||
});
|
||||
|
||||
test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "old$ ";
|
||||
@@ -148,6 +936,6 @@ test("does not refresh cached prompt from an unchanged mid-line write without a
|
||||
syncPromptLineBreakState(createFakeTerm("outputnew$ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "old$ ");
|
||||
assert.equal(state.pendingCommand, false);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
import { detectPrompt } from "../autocomplete/promptDetector";
|
||||
import {
|
||||
detectPrompt,
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
} from "../autocomplete/promptDetector";
|
||||
|
||||
export type PromptLineBreakState = {
|
||||
lastPromptText: string;
|
||||
@@ -86,6 +91,12 @@ const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean =>
|
||||
return prefixText.length > 0 && !endsWithLineBreak(prefixText);
|
||||
};
|
||||
|
||||
const isDistinctPromptText = (promptText: string): boolean => {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length >= 8) return true;
|
||||
return trimmed.length >= 6 && /[@:\\/]/.test(trimmed);
|
||||
};
|
||||
|
||||
const getCursorX = (term: XTerm): number => {
|
||||
try {
|
||||
return term.buffer.active.cursorX;
|
||||
@@ -104,12 +115,71 @@ export function createPromptLineBreakState(): PromptLineBreakState {
|
||||
|
||||
export function markPromptLineBreakCommandPending(
|
||||
stateRef?: RefObject<PromptLineBreakState>,
|
||||
term?: XTerm | null,
|
||||
command?: string,
|
||||
): void {
|
||||
if (!stateRef?.current) return;
|
||||
if (term) {
|
||||
const cachedFromCommand = command
|
||||
? cachePromptLineBreakPromptFromCommand(term, stateRef.current, command)
|
||||
: false;
|
||||
if (!cachedFromCommand) {
|
||||
cachePromptLineBreakPrompt(term, stateRef.current);
|
||||
}
|
||||
}
|
||||
stateRef.current.pendingCommand = true;
|
||||
stateRef.current.suppressNextPromptCache = false;
|
||||
}
|
||||
|
||||
function cachePromptLineBreakPromptFromCommand(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
command: string,
|
||||
): boolean {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!state || trimmedCommand.length === 0) return false;
|
||||
|
||||
const aligned = getAlignedPrompt(term, trimmedCommand, true);
|
||||
if (!aligned.prompt.isAtPrompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
if (isNonPromptLine(`${aligned.prompt.promptText}${trimmedCommand}`)) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
aligned.alignedTyped === trimmedCommand
|
||||
? aligned.prompt
|
||||
: reconcilePromptWithExternalCommand(aligned.prompt, trimmedCommand);
|
||||
if (!prompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cachePromptLineBreakPrompt(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
): void {
|
||||
if (!state) return;
|
||||
|
||||
const prompt = detectPrompt(term);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
if (prompt.userInput.length > 0) return;
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
}
|
||||
|
||||
export function insertPromptLineBreakBeforePrompt(
|
||||
data: string,
|
||||
promptText: string,
|
||||
@@ -123,7 +193,10 @@ export function insertPromptLineBreakBeforePrompt(
|
||||
const promptTextStart = mapped.text.length - promptText.length;
|
||||
const prefixText = mapped.text.slice(0, promptTextStart);
|
||||
if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data;
|
||||
if (prefixText.length > 0) return data;
|
||||
if (prefixText.length > 0) {
|
||||
if (endsWithLineBreak(prefixText)) return data;
|
||||
if (!isDistinctPromptText(promptText)) return data;
|
||||
}
|
||||
|
||||
const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0;
|
||||
return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`;
|
||||
@@ -144,11 +217,11 @@ export function prepareTerminalDataForPromptLineBreak(
|
||||
cursorXBeforeWrite,
|
||||
);
|
||||
const visibleText = mapVisibleText(data).text;
|
||||
const ambiguousPromptSuffix = hasAmbiguousPromptSuffix(data, state.lastPromptText);
|
||||
state.suppressNextPromptCache =
|
||||
nextData === data &&
|
||||
(cursorXBeforeWrite > 0 ||
|
||||
hasAmbiguousPromptSuffix(data, state.lastPromptText)) &&
|
||||
!containsLineReset(visibleText);
|
||||
(ambiguousPromptSuffix ||
|
||||
(cursorXBeforeWrite > 0 && !containsLineReset(visibleText)));
|
||||
return nextData;
|
||||
}
|
||||
|
||||
@@ -160,7 +233,6 @@ export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakSta
|
||||
|
||||
if (state.pendingCommand && state.suppressNextPromptCache) {
|
||||
state.suppressNextPromptCache = false;
|
||||
state.pendingCommand = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type MediaQueryListLike,
|
||||
watchDevicePixelRatio,
|
||||
} from "./rendererDprWatch";
|
||||
|
||||
class FakeMediaQueryList implements MediaQueryListLike {
|
||||
readonly query: string;
|
||||
modernListeners: Array<() => void> = [];
|
||||
legacyListeners: Array<() => void> = [];
|
||||
private readonly supportsModern: boolean;
|
||||
|
||||
constructor(query: string, supportsModern = true) {
|
||||
this.query = query;
|
||||
this.supportsModern = supportsModern;
|
||||
if (!supportsModern) {
|
||||
// Strip the modern API to emulate legacy environments.
|
||||
this.addEventListener = undefined;
|
||||
this.removeEventListener = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners.push(listener);
|
||||
};
|
||||
|
||||
removeEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
addListener = (listener: () => void) => {
|
||||
this.legacyListeners.push(listener);
|
||||
};
|
||||
|
||||
removeListener = (listener: () => void) => {
|
||||
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
trigger() {
|
||||
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
|
||||
}
|
||||
|
||||
get listenerCount() {
|
||||
return this.modernListeners.length + this.legacyListeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(initialDpr: number, supportsModern = true) {
|
||||
let dpr = initialDpr;
|
||||
const created: FakeMediaQueryList[] = [];
|
||||
return {
|
||||
created,
|
||||
getDevicePixelRatio: () => dpr,
|
||||
matchMedia: (query: string) => {
|
||||
const mql = new FakeMediaQueryList(query, supportsModern);
|
||||
created.push(mql);
|
||||
return mql;
|
||||
},
|
||||
setDpr: (value: number) => {
|
||||
dpr = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registers a change listener for the current devicePixelRatio", () => {
|
||||
const env = makeEnv(1);
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(env.created.length, 1);
|
||||
assert.equal(env.created[0].query, "(resolution: 1dppx)");
|
||||
assert.equal(env.created[0].listenerCount, 1);
|
||||
});
|
||||
|
||||
test("invokes onChange when the media query reports a change", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("re-registers for the new ratio so subsequent changes still fire", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(env.created.length, 2);
|
||||
assert.equal(env.created[1].query, "(resolution: 2dppx)");
|
||||
// The stale listener must be detached so it cannot double-fire.
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
|
||||
env.setDpr(3);
|
||||
env.created[1].trigger();
|
||||
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("cleanup stops further onChange callbacks", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
|
||||
const env = makeEnv(1, /* supportsModern */ false);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.created[0].legacyListeners.length, 1);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 1);
|
||||
|
||||
stop();
|
||||
// After cleanup the most recently registered query has no listeners.
|
||||
const latest = env.created[env.created.length - 1];
|
||||
assert.equal(latest.listenerCount, 0);
|
||||
});
|
||||
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
|
||||
* with different DPI, or changing the OS display scaling on Windows) and invokes
|
||||
* a callback so the renderer can be repaired.
|
||||
*
|
||||
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
|
||||
* device pixel ratio at creation time. When the ratio changes the cached glyphs
|
||||
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
|
||||
* corruption reported in issue #1049 that only goes away when a brand-new
|
||||
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
|
||||
* change so glyphs re-rasterize at the new scale.
|
||||
*
|
||||
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
|
||||
* change we must re-register the listener against the new ratio.
|
||||
*/
|
||||
export interface MediaQueryListLike {
|
||||
addEventListener?: (type: "change", listener: () => void) => void;
|
||||
removeEventListener?: (type: "change", listener: () => void) => void;
|
||||
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
}
|
||||
|
||||
export interface WatchDevicePixelRatioOptions {
|
||||
getDevicePixelRatio: () => number;
|
||||
matchMedia: (query: string) => MediaQueryListLike;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for devicePixelRatio changes. Returns a cleanup function that
|
||||
* removes the active listener.
|
||||
*/
|
||||
export function watchDevicePixelRatio(
|
||||
options: WatchDevicePixelRatioOptions,
|
||||
): () => void {
|
||||
const { getDevicePixelRatio, matchMedia, onChange } = options;
|
||||
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
|
||||
|
||||
const detach = () => {
|
||||
if (!current) return;
|
||||
const { mql, listener } = current;
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener("change", listener);
|
||||
} else if (mql.removeListener) {
|
||||
mql.removeListener(listener);
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
const dpr = getDevicePixelRatio();
|
||||
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
|
||||
const listener = () => {
|
||||
// A media query only matches the ratio it was created with, so detach the
|
||||
// stale listener and re-register against the new ratio before notifying.
|
||||
detach();
|
||||
attach();
|
||||
onChange();
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", listener);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(listener);
|
||||
}
|
||||
current = { mql, listener };
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return detach;
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Host } from "../../../types";
|
||||
import {
|
||||
markPromptLineBreakCommandPending,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
} from "../autocomplete/promptDetector";
|
||||
|
||||
type TerminalCommandExecutionContext = {
|
||||
host: Pick<Host, "id" | "label">;
|
||||
@@ -18,14 +24,34 @@ type TerminalCommandExecutionContext = {
|
||||
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
|
||||
};
|
||||
|
||||
const shouldRecordShellHistory = (
|
||||
command: string,
|
||||
term?: XTerm | null,
|
||||
): boolean => {
|
||||
if (!term) return true;
|
||||
|
||||
const { prompt, alignedTyped } = getAlignedPrompt(term, command, true);
|
||||
if (!prompt.isAtPrompt) return false;
|
||||
if (alignedTyped?.trim() === command.trim()) return true;
|
||||
|
||||
if (reconcilePromptWithExternalCommand(prompt, command)) return true;
|
||||
|
||||
const liveCommand = prompt.userInput.trim();
|
||||
if (liveCommand.length === 0) {
|
||||
return !isNonPromptLine(`${prompt.promptText}${command.trim()}`);
|
||||
}
|
||||
return liveCommand === command.trim();
|
||||
};
|
||||
|
||||
export const recordTerminalCommandExecution = (
|
||||
command: string,
|
||||
ctx: TerminalCommandExecutionContext,
|
||||
term?: XTerm | null,
|
||||
) => {
|
||||
const cmd = command.trim();
|
||||
if (cmd) {
|
||||
if (cmd && shouldRecordShellHistory(cmd, term)) {
|
||||
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
ctx.commandBufferRef.current = "";
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, command);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
clearPasteResidualAfterTerminalWrite,
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
prepareTerminalDataForUserPasteDisplay,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
@@ -151,6 +152,95 @@ test("broadcast gate consumes paste state even when broadcast is disabled before
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate suppresses expected terminal cursor position report replies", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;1R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate suppresses cursor position report replies split across chunks", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "24;", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate preserves normal input while a cursor position report is pending", () => {
|
||||
const term = {};
|
||||
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls\r", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[24;80R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("broadcast gate preserves keyboard sequences that look like cursor reports without a terminal request", () => {
|
||||
const term = {};
|
||||
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "\x1b[1;2R", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("user paste preserves the existing scroll-on-paste behavior", () => {
|
||||
const calls: string[] = [];
|
||||
const term = {
|
||||
|
||||
@@ -29,12 +29,20 @@ type PasteInputScrollState = {
|
||||
remainingDataVariants: string[];
|
||||
};
|
||||
|
||||
type TerminalProtocolReplyState = {
|
||||
expiresAt: number;
|
||||
pendingCursorPositionReports: number;
|
||||
cursorPositionReportFragment: string;
|
||||
};
|
||||
|
||||
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
|
||||
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const terminalProtocolReplyStates = new WeakMap<object, TerminalProtocolReplyState>();
|
||||
const LONG_PASTE_MIN_LENGTH = 200;
|
||||
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
|
||||
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
|
||||
const TERMINAL_PROTOCOL_REPLY_WINDOW_MS = 4000;
|
||||
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
|
||||
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
@@ -116,6 +124,45 @@ const getPlainTerminalText = (data: string): string =>
|
||||
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
|
||||
type CursorPositionReportMatch =
|
||||
| { type: "complete"; length: number }
|
||||
| { type: "prefix" }
|
||||
| { type: "none" };
|
||||
|
||||
const isAsciiDigit = (char: string): boolean => char >= "0" && char <= "9";
|
||||
|
||||
const matchCursorPositionReportFromStart = (data: string): CursorPositionReportMatch => {
|
||||
if (!data.startsWith(ESC)) return { type: "none" };
|
||||
if (data.length === 1) return { type: "prefix" };
|
||||
if (data[1] !== "[") return { type: "none" };
|
||||
if (data.length === 2) return { type: "prefix" };
|
||||
|
||||
let index = 2;
|
||||
if (data[index] === "?") {
|
||||
index += 1;
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
}
|
||||
|
||||
let rowDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
rowDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (rowDigits === 0 || data[index] !== ";") return { type: "none" };
|
||||
|
||||
index += 1;
|
||||
let columnDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
columnDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (columnDigits === 0 || data[index] !== "R") return { type: "none" };
|
||||
|
||||
return { type: "complete", length: index + 1 };
|
||||
};
|
||||
|
||||
const getPasteEchoFragments = (text: string): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
@@ -304,13 +351,81 @@ export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data:
|
||||
return consumePasteInputState(pasteBroadcastStates, term, data);
|
||||
}
|
||||
|
||||
export function markExpectedTerminalCursorPositionReport(term: object): void {
|
||||
const currentState = terminalProtocolReplyStates.get(term);
|
||||
const activeState = isStateActive(currentState)
|
||||
? currentState
|
||||
: {
|
||||
expiresAt: 0,
|
||||
pendingCursorPositionReports: 0,
|
||||
cursorPositionReportFragment: "",
|
||||
};
|
||||
|
||||
terminalProtocolReplyStates.set(term, {
|
||||
expiresAt: getNow() + TERMINAL_PROTOCOL_REPLY_WINDOW_MS,
|
||||
pendingCursorPositionReports: activeState.pendingCursorPositionReports + 1,
|
||||
cursorPositionReportFragment: activeState.cursorPositionReportFragment,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSuppressTerminalProtocolReplyBroadcast(term: object, data: string): boolean {
|
||||
const state = terminalProtocolReplyStates.get(term);
|
||||
if (!isStateActive(state)) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.pendingCursorPositionReports <= 0 || data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let remainingData = `${state.cursorPositionReportFragment}${data}`;
|
||||
let consumedCursorPositionReports = 0;
|
||||
|
||||
while (remainingData.length > 0) {
|
||||
const match = matchCursorPositionReportFromStart(remainingData);
|
||||
if (match.type === "none") {
|
||||
state.cursorPositionReportFragment = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match.type === "prefix") {
|
||||
if (consumedCursorPositionReports >= state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = remainingData;
|
||||
return true;
|
||||
}
|
||||
|
||||
consumedCursorPositionReports += 1;
|
||||
if (consumedCursorPositionReports > state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
remainingData = remainingData.slice(match.length);
|
||||
}
|
||||
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = "";
|
||||
if (state.pendingCursorPositionReports <= 0) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldBroadcastTerminalUserInput(
|
||||
term: object,
|
||||
data: string,
|
||||
options: BroadcastUserInputOptions,
|
||||
): boolean {
|
||||
const isSuppressedUserPaste = shouldSuppressTerminalBroadcastForUserPaste(term, data);
|
||||
return !isSuppressedUserPaste && !!options.isBroadcastEnabled && !!options.hasBroadcastInputHandler;
|
||||
const isSuppressedTerminalProtocolReply = shouldSuppressTerminalProtocolReplyBroadcast(term, data);
|
||||
return (
|
||||
!isSuppressedUserPaste &&
|
||||
!isSuppressedTerminalProtocolReply &&
|
||||
!!options.isBroadcastEnabled &&
|
||||
!!options.hasBroadcastInputHandler
|
||||
);
|
||||
}
|
||||
|
||||
function consumePasteInputState(
|
||||
|
||||
61
components/terminal/sftpCwd.test.ts
Normal file
61
components/terminal/sftpCwd.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
resolvePreferredTerminalCwd,
|
||||
} from "./sftpCwd";
|
||||
|
||||
test("resolvePreferredTerminalCwd returns the renderer cwd without probing the backend", async () => {
|
||||
let backendCalls = 0;
|
||||
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/srv/app/current",
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => {
|
||||
backendCalls += 1;
|
||||
return { success: true, cwd: "/root" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/srv/app/current");
|
||||
assert.equal(backendCalls, 0);
|
||||
});
|
||||
|
||||
test("resolvePreferredTerminalCwd falls back to backend pwd when no renderer cwd is known", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: undefined,
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async (sessionId) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
return { success: true, cwd: "/home/alice" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/home/alice");
|
||||
});
|
||||
|
||||
test("resolvePreferredTerminalCwd returns null when neither source has a cwd", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "",
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
assert.equal(cwd, null);
|
||||
});
|
||||
|
||||
test("terminal cwd tracker clears stale renderer cwd before falling back to backend pwd", async () => {
|
||||
const tracker = createTerminalCwdTracker();
|
||||
|
||||
tracker.setRendererCwd("/srv/old-session");
|
||||
tracker.clearRendererCwd();
|
||||
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: tracker.getRendererCwd(),
|
||||
sessionId: "session-1",
|
||||
getSessionPwd: async () => ({ success: true, cwd: "/home/fresh-session" }),
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/home/fresh-session");
|
||||
});
|
||||
53
components/terminal/sftpCwd.ts
Normal file
53
components/terminal/sftpCwd.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
type SessionPwdResult = {
|
||||
success: boolean;
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
type ResolvePreferredTerminalCwdOptions = {
|
||||
rendererCwd?: string | null;
|
||||
sessionId?: string | null;
|
||||
getSessionPwd: (sessionId: string) => Promise<SessionPwdResult>;
|
||||
};
|
||||
|
||||
const normalizeCwd = (cwd?: string | null): string | null => {
|
||||
if (typeof cwd !== "string" || cwd.trim().length === 0) return null;
|
||||
return cwd;
|
||||
};
|
||||
|
||||
export type TerminalCwdTracker = {
|
||||
getRendererCwd: () => string | undefined;
|
||||
setRendererCwd: (cwd?: string | null) => string | undefined;
|
||||
clearRendererCwd: () => void;
|
||||
};
|
||||
|
||||
export const createTerminalCwdTracker = (): TerminalCwdTracker => {
|
||||
let rendererCwd: string | undefined;
|
||||
|
||||
return {
|
||||
getRendererCwd: () => rendererCwd,
|
||||
setRendererCwd: (cwd) => {
|
||||
rendererCwd = normalizeCwd(cwd) ?? undefined;
|
||||
return rendererCwd;
|
||||
},
|
||||
clearRendererCwd: () => {
|
||||
rendererCwd = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePreferredTerminalCwd = async ({
|
||||
rendererCwd,
|
||||
sessionId,
|
||||
getSessionPwd,
|
||||
}: ResolvePreferredTerminalCwdOptions): Promise<string | null> => {
|
||||
const knownCwd = normalizeCwd(rendererCwd);
|
||||
if (knownCwd) return knownCwd;
|
||||
if (!sessionId) return null;
|
||||
|
||||
try {
|
||||
const result = await getSessionPwd(sessionId);
|
||||
return result.success ? normalizeCwd(result.cwd) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
48
components/terminal/snippetCompleter.test.ts
Normal file
48
components/terminal/snippetCompleter.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const snip = (over: Partial<Snippet>): Snippet => ({
|
||||
id: over.id ?? "s1",
|
||||
label: over.label ?? "deploy",
|
||||
command: over.command ?? "echo deploy",
|
||||
...over,
|
||||
});
|
||||
|
||||
test("matches by label prefix and carries the snippet + command preview", () => {
|
||||
const s = snip({ id: "a", label: "deploy", command: "kubectl apply -f .\nkubectl rollout status deploy" });
|
||||
const out = getSnippetSuggestions("dep", [s], {});
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].source, "snippet");
|
||||
assert.equal(out[0].displayText, "deploy");
|
||||
assert.equal(out[0].description, "kubectl apply -f .\nkubectl rollout status deploy");
|
||||
assert.equal(out[0].snippet?.id, "a");
|
||||
});
|
||||
|
||||
test("matches by command first line", () => {
|
||||
const s = snip({ id: "b", label: "k8s", command: "kubectl get pods" });
|
||||
const out = getSnippetSuggestions("kubectl", [s], {});
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].snippet?.id, "b");
|
||||
});
|
||||
|
||||
test("is case-insensitive and prefix outranks substring", () => {
|
||||
const a = snip({ id: "p", label: "Backup", command: "tar czf b.tgz ." });
|
||||
const b = snip({ id: "q", label: "db-backup", command: "pg_dump" });
|
||||
const out = getSnippetSuggestions("backup", [a, b], {});
|
||||
assert.deepEqual(out.map((o) => o.snippet?.id), ["p", "q"]);
|
||||
});
|
||||
|
||||
test("filters by host targets when set", () => {
|
||||
const scoped = snip({ id: "t", label: "restart", command: "systemctl restart x", targets: ["host-2"] });
|
||||
const global = snip({ id: "g", label: "restart-all", command: "echo all" });
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-1" }).map((o) => o.snippet?.id), ["g"]);
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
|
||||
});
|
||||
|
||||
test("no match returns empty; empty input returns empty", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
|
||||
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
|
||||
});
|
||||
@@ -34,6 +34,9 @@ export const terminalLayerAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.toggleSidePanelRef === next.toggleSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
|
||||
68
domain/connectionLog.test.ts
Normal file
68
domain/connectionLog.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { ConnectionLog } from "./models.ts";
|
||||
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-base",
|
||||
sessionId: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Example",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
protocol: "ssh",
|
||||
startTime: 1000,
|
||||
localUsername: "local",
|
||||
localHostname: "machine",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
|
||||
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
|
||||
const staleLog = {
|
||||
...baseLog,
|
||||
id: "stale",
|
||||
sessionId: "session-2",
|
||||
startTime: 3000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
selectConnectionLogForTerminalDataCapture(
|
||||
[staleLog, matchingLog],
|
||||
{ sessionId: "session-1", hostname: "example.com" },
|
||||
)?.id,
|
||||
"active",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectConnectionLogForTerminalDataCapture reuses the latest log for repeated captures after reconnect", () => {
|
||||
const firstCapture = {
|
||||
...baseLog,
|
||||
id: "first-capture",
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
terminalData: "first disconnect",
|
||||
};
|
||||
const olderSameSession = {
|
||||
...baseLog,
|
||||
id: "older-same-session",
|
||||
startTime: 1500,
|
||||
endTime: 1800,
|
||||
terminalData: "older data",
|
||||
};
|
||||
const otherSession = {
|
||||
...baseLog,
|
||||
id: "other-session",
|
||||
sessionId: "session-2",
|
||||
startTime: 3000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
selectConnectionLogForTerminalDataCapture(
|
||||
[otherSession, olderSameSession, firstCapture],
|
||||
{ sessionId: "session-1", hostname: "example.com" },
|
||||
)?.id,
|
||||
"first-capture",
|
||||
);
|
||||
});
|
||||
25
domain/connectionLog.ts
Normal file
25
domain/connectionLog.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ConnectionLog } from "./models.ts";
|
||||
|
||||
interface TerminalDataCaptureTarget {
|
||||
sessionId: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export const selectConnectionLogForTerminalDataCapture = (
|
||||
connectionLogs: ConnectionLog[],
|
||||
target: TerminalDataCaptureTarget,
|
||||
): ConnectionLog | undefined => {
|
||||
const matchingOpenLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === target.sessionId;
|
||||
return !!target.hostname && log.hostname === target.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
|
||||
if (matchingOpenLog) return matchingOpenLog;
|
||||
|
||||
return connectionLogs
|
||||
.filter((log) => log.sessionId === target.sessionId)
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
};
|
||||
@@ -208,3 +208,32 @@ test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamily, "jetbrains-mono");
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits skipEcdsaHostKey from the group when host has no value", () => {
|
||||
const result = applyGroupDefaults(host(), { skipEcdsaHostKey: true });
|
||||
assert.equal(result.skipEcdsaHostKey, true);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps host-level skipEcdsaHostKey instead of group default", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ skipEcdsaHostKey: false }),
|
||||
{ skipEcdsaHostKey: true },
|
||||
);
|
||||
assert.equal(result.skipEcdsaHostKey, false);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits algorithm overrides from the group", () => {
|
||||
const overrides = { serverHostKey: ["ssh-rsa", "ssh-dss"] };
|
||||
const result = applyGroupDefaults(host(), { algorithms: overrides });
|
||||
assert.deepEqual(result.algorithms, overrides);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps host algorithm overrides instead of inheriting", () => {
|
||||
const hostOverrides = { kex: ["curve25519-sha256"] };
|
||||
const groupOverrides = { kex: ["diffie-hellman-group14-sha256"] };
|
||||
const result = applyGroupDefaults(
|
||||
host({ algorithms: hostOverrides }),
|
||||
{ algorithms: groupOverrides },
|
||||
);
|
||||
assert.deepEqual(result.algorithms, hostOverrides);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,8 @@ export function resolveGroupDefaults(
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand',
|
||||
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
|
||||
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
|
||||
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
|
||||
'backspaceBehavior',
|
||||
|
||||
@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import {
|
||||
detectVendorFromSshVersion,
|
||||
normalizePrimaryTelnetState,
|
||||
resolveHostKeepalive,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
shouldProbeSessionCwd,
|
||||
upsertHostById,
|
||||
} from "./host.ts";
|
||||
|
||||
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
|
||||
assert.equal(detectVendorFromSshVersion("-"), "huawei");
|
||||
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
|
||||
// Reconnect / manual deviceType='network': host.distro already says network.
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
|
||||
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
|
||||
// isNetworkDevice is still false — the banner is the only signal (#1043).
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
test("resolveHostKeepalive falls back to global when override is not set", () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim();
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
* populated yet, so we also inspect the SSH server identification banner —
|
||||
* captured for free at handshake — which identifies most vendors directly.
|
||||
*/
|
||||
export const shouldProbeSessionCwd = (opts: {
|
||||
isNetworkDevice: boolean;
|
||||
remoteSshVersion?: string;
|
||||
}): boolean =>
|
||||
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
|
||||
64
domain/models.test.ts
Normal file
64
domain/models.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { keyEventToString, matchesKeyBinding } from './models.ts';
|
||||
|
||||
const keyboardEvent = (
|
||||
key: string,
|
||||
code: string,
|
||||
modifiers: Partial<KeyboardEvent> = {},
|
||||
): KeyboardEvent => ({
|
||||
key,
|
||||
code,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...modifiers,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test('shortcut matching falls back to physical keys for non-Latin layouts', () => {
|
||||
const event = keyboardEvent('\u0446', 'KeyW', { ctrlKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), true);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + W');
|
||||
});
|
||||
|
||||
test('shortcut matching respects Latin characters from non-QWERTY layouts', () => {
|
||||
const event = keyboardEvent('w', 'Comma', { ctrlKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), true);
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + ,', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + W');
|
||||
});
|
||||
|
||||
test('shortcut matching respects non-ASCII Latin layout characters', () => {
|
||||
const event = keyboardEvent('ß', 'Minus', { ctrlKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + ß', false), true);
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + -', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + ß');
|
||||
});
|
||||
|
||||
test('shortcut matching respects punctuation characters from non-QWERTY layouts', () => {
|
||||
const event = keyboardEvent(',', 'KeyW', { ctrlKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + ,', false), true);
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + ,');
|
||||
});
|
||||
|
||||
test('shortcut matching keeps physical digit ranges layout-independent', () => {
|
||||
const event = keyboardEvent('&', 'Digit1', { ctrlKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + [1...9]', false), true);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + &');
|
||||
});
|
||||
|
||||
test('shortcut matching preserves shifted number-row symbols', () => {
|
||||
const event = keyboardEvent('!', 'Digit1', { ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + !', false), true);
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + 1', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + Shift + !');
|
||||
});
|
||||
@@ -24,6 +24,18 @@ export interface HostChainConfig {
|
||||
hostIds: string[]; // Array of host IDs in order (first = closest to client)
|
||||
}
|
||||
|
||||
// Per-host SSH algorithm override lists (advanced). Each property, when
|
||||
// present and non-empty, fully replaces the offered list for that category.
|
||||
// Category names mirror ssh2's `algorithms` shape (note: `compress`, not
|
||||
// `compression`). Empty arrays or missing properties keep the default.
|
||||
export interface HostAlgorithmOverrides {
|
||||
kex?: string[];
|
||||
cipher?: string[];
|
||||
hmac?: string[];
|
||||
serverHostKey?: string[];
|
||||
compress?: string[];
|
||||
}
|
||||
|
||||
// Environment variable for SSH session
|
||||
export interface EnvVar {
|
||||
name: string;
|
||||
@@ -129,6 +141,15 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// Drop every ecdsa-sha2-* from the offered host-key list. Some old Huawei
|
||||
// VRP / Cisco IOS stacks negotiate ECDSA but produce signatures ssh2's
|
||||
// strict RFC verifier rejects ("signature verification failed"). Forcing
|
||||
// RSA / DSA / Ed25519 fallback restores compatibility — see #1027.
|
||||
skipEcdsaHostKey?: boolean;
|
||||
// Per-host SSH algorithm overrides (advanced). When a category's array is
|
||||
// non-empty, it fully replaces the offered list for that category. Use
|
||||
// sparingly — incorrect values make the host unreachable.
|
||||
algorithms?: HostAlgorithmOverrides;
|
||||
// Per-host SSH keepalive override. When `keepaliveOverride === true`, the
|
||||
// host uses its own `keepaliveInterval` / `keepaliveCountMax` instead of
|
||||
// inheriting the global TerminalSettings values. Lets a user keep an
|
||||
@@ -229,6 +250,8 @@ export interface GroupConfig {
|
||||
hostChain?: HostChainConfig;
|
||||
startupCommand?: string;
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithms?: HostAlgorithmOverrides;
|
||||
environmentVariables?: EnvVar[];
|
||||
charset?: string;
|
||||
moshEnabled?: boolean;
|
||||
@@ -278,6 +301,42 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
|
||||
return { modifiers: parts, key };
|
||||
};
|
||||
|
||||
const PHYSICAL_SHORTCUT_KEY_NAMES: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
};
|
||||
|
||||
const physicalShortcutKeyName = (e: KeyboardEvent): string | null => {
|
||||
const code = e.code;
|
||||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||
return PHYSICAL_SHORTCUT_KEY_NAMES[code] ?? null;
|
||||
};
|
||||
|
||||
const LATIN_SHORTCUT_KEY_PATTERN = /^\p{Script=Latin}$/u;
|
||||
const ASCII_SHORTCUT_KEY_PATTERN = /^[A-Za-z]$/;
|
||||
const PRINTABLE_NON_LETTER_SHORTCUT_KEY_PATTERN = /^[^\p{Letter}\p{Number}\s]$/u;
|
||||
|
||||
const shortcutEventKey = (e: KeyboardEvent): string => {
|
||||
const physicalKey = physicalShortcutKeyName(e);
|
||||
if (
|
||||
LATIN_SHORTCUT_KEY_PATTERN.test(e.key) ||
|
||||
PRINTABLE_NON_LETTER_SHORTCUT_KEY_PATTERN.test(e.key)
|
||||
) {
|
||||
return e.key;
|
||||
}
|
||||
return physicalKey ?? e.key;
|
||||
};
|
||||
|
||||
// Convert keyboard event to a key string
|
||||
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
const parts: string[] = [];
|
||||
@@ -295,7 +354,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
}
|
||||
|
||||
// Get the key name
|
||||
let keyName = e.key;
|
||||
let keyName = shortcutEventKey(e);
|
||||
// Normalize special keys
|
||||
if (keyName === ' ') keyName = 'Space';
|
||||
else if (keyName === 'ArrowUp') keyName = '↑';
|
||||
@@ -307,7 +366,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
else if (keyName === 'Delete') keyName = 'Del';
|
||||
else if (keyName === 'Enter') keyName = '↵';
|
||||
else if (keyName === 'Tab') keyName = '⇥';
|
||||
else if (keyName.length === 1) keyName = keyName.toUpperCase();
|
||||
else if (ASCII_SHORTCUT_KEY_PATTERN.test(keyName)) keyName = keyName.toUpperCase();
|
||||
|
||||
// Don't include modifier keys themselves
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
|
||||
@@ -325,11 +384,19 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
// Handle range patterns like "[1...9]"
|
||||
if (keyStr.includes('[1...9]')) {
|
||||
const basePattern = keyStr.replace('[1...9]', '');
|
||||
const key = e.key;
|
||||
const key = physicalShortcutKeyName(e) ?? shortcutEventKey(e);
|
||||
if (!/^[1-9]$/.test(key)) return false;
|
||||
// Check modifiers match the base pattern
|
||||
const testStr = basePattern + key;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
const physicalDigitEvent = {
|
||||
key,
|
||||
code: e.code,
|
||||
metaKey: e.metaKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
shiftKey: e.shiftKey,
|
||||
} as KeyboardEvent;
|
||||
return matchesKeyBinding(physicalDigitEvent, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
// Handle arrow key patterns like "arrows"
|
||||
@@ -398,7 +465,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
return normalizedKey;
|
||||
};
|
||||
|
||||
const eventKey = normalizeKey(e.key);
|
||||
const eventKey = normalizeKey(shortcutEventKey(e));
|
||||
const parsedKey = normalizeKey(key);
|
||||
|
||||
return eventKey.toLowerCase() === parsedKey.toLowerCase();
|
||||
@@ -435,6 +502,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 +544,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
|
||||
@@ -493,6 +562,7 @@ export interface TerminalSettings {
|
||||
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
@@ -683,6 +753,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollback: 10000,
|
||||
drawBoldInBrightColors: true,
|
||||
terminalEmulationType: 'xterm-256color',
|
||||
startupCommandDelayMs: 600,
|
||||
fontLigatures: true,
|
||||
fontWeight: 400,
|
||||
fontWeightBold: 700,
|
||||
@@ -692,6 +763,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
cursorBlink: true,
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
@@ -720,7 +792,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
forcePromptNewLine: true, // Keep the next shell prompt visually separated from unterminated final output lines
|
||||
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
|
||||
86
domain/sshAlgorithmList.test.ts
Normal file
86
domain/sshAlgorithmList.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
effectiveDefaultAlgorithms,
|
||||
SUPPORTED_ALGORITHMS_BY_CATEGORY,
|
||||
} from "./sshAlgorithmList.ts";
|
||||
const requireSsh2 = createRequire(import.meta.url);
|
||||
// Anchor the UI editor's supported lists to what ssh2 will actually
|
||||
// accept at connect time. If ssh2 drops a cipher / KEX / MAC at any
|
||||
// point (OpenSSL 3 already removed blowfish / arcfour / cast128, for
|
||||
// example) the editor must not offer it — picking it would throw
|
||||
// "Unsupported algorithm" synchronously before negotiation.
|
||||
const ssh2Constants = requireSsh2("ssh2/lib/protocol/constants.js");
|
||||
const SSH2_SUPPORTED_BY_CATEGORY: Record<string, readonly string[]> = {
|
||||
kex: ssh2Constants.SUPPORTED_KEX,
|
||||
cipher: ssh2Constants.SUPPORTED_CIPHER,
|
||||
hmac: ssh2Constants.SUPPORTED_MAC,
|
||||
serverHostKey: ssh2Constants.SUPPORTED_SERVER_HOST_KEY,
|
||||
compress: ssh2Constants.SUPPORTED_COMPRESSION,
|
||||
};
|
||||
|
||||
test("effectiveDefaultAlgorithms (modern) never seeds legacy SHA-1 KEX", () => {
|
||||
const result = effectiveDefaultAlgorithms(false);
|
||||
assert.ok(!result.kex.includes("diffie-hellman-group1-sha1"));
|
||||
assert.ok(!result.kex.includes("diffie-hellman-group14-sha1"));
|
||||
assert.ok(!result.kex.includes("diffie-hellman-group-exchange-sha1"));
|
||||
// Modern KEX still present.
|
||||
assert.ok(result.kex.includes("curve25519-sha256"));
|
||||
assert.ok(result.kex.includes("diffie-hellman-group14-sha256"));
|
||||
});
|
||||
|
||||
test("effectiveDefaultAlgorithms (modern) never seeds CBC / arcfour / MD5", () => {
|
||||
const result = effectiveDefaultAlgorithms(false);
|
||||
for (const algo of result.cipher) {
|
||||
assert.ok(!algo.endsWith("-cbc"), `${algo} is a CBC cipher and should not be in modern defaults`);
|
||||
assert.ok(!algo.startsWith("arcfour"), `${algo} (arcfour) should not be in modern defaults`);
|
||||
assert.ok(algo !== "3des-cbc", "3des-cbc is legacy");
|
||||
}
|
||||
for (const algo of result.hmac) {
|
||||
assert.ok(!algo.includes("md5"), `${algo} should not be in modern defaults`);
|
||||
}
|
||||
});
|
||||
|
||||
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
|
||||
const modern = effectiveDefaultAlgorithms(false);
|
||||
const legacy = effectiveDefaultAlgorithms(true);
|
||||
|
||||
// Every modern algorithm is still present.
|
||||
for (const category of Object.keys(modern) as (keyof typeof modern)[]) {
|
||||
for (const algo of modern[category]) {
|
||||
assert.ok(legacy[category].includes(algo), `${algo} missing from legacy ${category}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(legacy.kex.includes("diffie-hellman-group14-sha1"));
|
||||
assert.ok(legacy.kex.includes("diffie-hellman-group1-sha1"));
|
||||
assert.ok(legacy.cipher.includes("aes128-cbc"));
|
||||
assert.ok(legacy.cipher.includes("3des-cbc"));
|
||||
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
|
||||
});
|
||||
|
||||
test("SUPPORTED_ALGORITHMS_BY_CATEGORY only lists algorithms ssh2 will actually accept", () => {
|
||||
for (const category of Object.keys(SUPPORTED_ALGORITHMS_BY_CATEGORY) as (keyof typeof SUPPORTED_ALGORITHMS_BY_CATEGORY)[]) {
|
||||
const ssh2Supported = SSH2_SUPPORTED_BY_CATEGORY[category];
|
||||
assert.ok(ssh2Supported, `unexpected category ${category}`);
|
||||
for (const algo of SUPPORTED_ALGORITHMS_BY_CATEGORY[category]) {
|
||||
assert.ok(
|
||||
ssh2Supported.includes(algo),
|
||||
`${algo} (${category}) is in the UI list but ssh2 would reject it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("effectiveDefaultAlgorithms output is a subset of SUPPORTED_ALGORITHMS_BY_CATEGORY", () => {
|
||||
for (const enabled of [false, true]) {
|
||||
const result = effectiveDefaultAlgorithms(enabled);
|
||||
for (const category of Object.keys(result) as (keyof typeof result)[]) {
|
||||
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
|
||||
for (const algo of result[category]) {
|
||||
assert.ok(supported.includes(algo), `${algo} (${category}) not in supported list`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user