Compare commits

..
Author SHA1 Message Date
bincxz 3fd954d2bf fix(sftp): keep selection scroll on reconnect listing updates 2026-09-27 22:11:35 +08:00
bincxz ff28c9b412 revert(sftp): remove terminal command auto-refresh (#3371) 2026-09-27 22:03:47 +08:00
陈大猫 7006fa214e Merge pull request #3538 from binaricat/codex/revert-3504-sftp-sidebar
revert(vault): remove SFTP sidebar option pending #3504 decision
2026-09-27 21:27:09 +08:00
bincxz 459f3a5278 revert(vault): remove SFTP sidebar option from #3504
Revert the changes merged in #3506 while preserving the later bottom-tab removal.
2026-09-27 21:14:53 +08:00
陈大猫 6c8edddfa8 Merge pull request #3537 from binaricat/codex/revert-3366-bottom-tabs
revert(ui): remove bottom tab bar option from #3366
2026-09-27 21:07:51 +08:00
bincxz 5af2c86f8f revert(ui): remove bottom tab bar option from #3366 2026-09-27 20:51:27 +08:00
陈大猫 7b1151f976 Merge pull request #3536 from binaricat/codex/3509-preserve-ssh-config-read-failure
fix(vault): preserve managed SSH config when source read fails
2026-09-27 18:14:04 +08:00
bincxz d21016b91a fix(vault): stop managed SSH config writes after read failure 2026-09-27 18:03:09 +08:00
陈大猫 b62a0b3a8f Merge pull request #3522 from binaricat/ai/issue-3519-35968132212
fix(sync): skip identical cloud uploads that inflated versions while idle
2026-09-27 17:53:05 +08:00
bincxz 3560a04fa6 Keep peer sync metadata writes from canceling no-op sync 2026-09-27 17:41:36 +08:00
bincxz a373f57905 Notify idle state after unchanged provider sync 2026-09-27 17:33:14 +08:00
bincxz e376190a70 Fix unchanged per-provider sync and startup identity snapshot 2026-09-27 17:29:53 +08:00
bincxz 037a2f651b fix(sync): ignore peer no-op metadata in identity guard 2026-09-27 16:48:38 +08:00
bincxz 1a4a022a8e fix(sync): distinguish token refresh from provider switch 2026-09-27 16:36:42 +08:00
bincxz fd177e8453 fix(sync): detect provider changes before storage events arrive 2026-09-27 16:29:42 +08:00
bincxz dee468dcfa fix(sync): guard no-op persistence across provider changes 2026-09-27 16:25:40 +08:00
bincxz c666fdb156 fix(sync): keep newer provider credentials during no-op 2026-09-27 16:15:53 +08:00
bincxz b4111cf970 test(sync): verify two devices settle after shared edits 2026-09-27 16:03:39 +08:00
69497ccc6a fix(terminal): offer saved password identities in SSH re-auth dialog (#3477)
* fix(#3475): automated AI fix

* fix: address Codex review on PR #3477

* fix: keep SSH identity picker state accurate

* style: keep SSH identity picker source ASCII-only

* fix: require usernames for saved SSH identity retries

* Keep selected SSH identity for terminal SFTP

* Share reauthenticated identity with all terminal SFTP entry points

* Pass SFTP identity source setter into layer effects

* Use effective identity for preset SFTP panel

* fix: keep focused SFTP identity in sync for toggle

* fix(terminal): use selected vault identity without exposing password

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-27 15:43:25 +08:00
Netcatty-Botandnetcatty-bot 078353dc87 fix(ai): allow custom model ids in composer picker and persist manual picks (#3535)
* fix(#3534): automated AI fix

* fix: address Codex review on PR #3535

* fix: address Codex review on PR #3535

* fix: address Codex review on PR #3535

* fix: address Codex review on PR #3535

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-27 14:57:10 +08:00
fb7adc3bfc fix(sftp): fall back to copy on EISDIR from hardlink publication (#3484)
* fix(#3483): automated AI fix

* test(sftp): cover EISDIR through local download publication

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-27 14:42:45 +08:00
bincxz 8d6ef72006 fix(sync): stop upload when no-op state persistence fails 2026-09-27 14:30:07 +08:00
陈大猫 85683757e5 Merge pull request #3532 from binaricat/codex/claude-model-label-smoke
fix(ai): distinguish Claude aliases sharing model names
2026-09-25 21:24:03 +08:00
bincxz 7224ad37c8 fix(ai): distinguish Claude aliases sharing model names 2026-09-25 21:06:31 +08:00
bincxz 598f5cf796 test(sync): cover vault lock during no-op base save 2026-09-25 17:51:51 +08:00
bincxz ed92944af0 fix(sync): stop no-op commit after vault security changes 2026-09-25 17:50:33 +08:00
bincxz e2b3daac9a test(sync): cover download-remote source no-op with stale peer 2026-09-25 17:44:26 +08:00
85bddba538 feat(ai): add bottom dock position for AI assistant side panel (#3482)
* fix(#3481): automated AI fix

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: remeasure bottom dock when compose bar toggles

* fix: preserve terminal height when bottom dock cannot fit

* fix: preserve dock preference through temporary shrinkage

* fix: update bottom dock when compose bar toggles

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:35:49 +08:00
Netcatty-Botandnetcatty-bot 66e87a95f1 fix(ai-cli): self-heal tool-CLI discovery file while TCP bridge is alive (#3502)
* fix(#3501): automated AI fix

* fix: address Codex review on PR #3502

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-25 17:33:57 +08:00
7cce9c9dbb fix(terminal): center titlebar window control buttons in the title bar (#3478)
* fix(#3476): automated AI fix

* fix: align window controls with full title bar

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:28:51 +08:00
954b60acf2 feat(vault): keep SFTP in the sidebar with a persistent vault rail (#3506)
* fix(#3504): automated AI fix

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: keep sidebar SFTP visible above inactive terminal

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:15:43 +08:00
73284582f5 fix(ai): keep Claude/Codex/Grok model pickers on live CLI catalogs instead of build-time presets (#3499)
* fix(#3496): automated AI fix

* fix: address Codex review on PR #3499

* fix: address Codex review on PR #3499

* fix: keep live model catalogs scoped and surface discovery failures

* fix: isolate Claude catalog profiles and support xhigh effort

* fix: handle missing external model catalog state

* fix: isolate Claude model catalogs by credentials

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:15:03 +08:00
33853398d4 fix(keychain): detect ECDSA key type in imported OpenSSH and PKCS#8 keys (#3490)
* fix(#3489): automated AI fix

* fix(keychain): read OpenSSH algorithm field precisely

* fix: preserve imported key size after vault reload

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 16:51:13 +08:00
陈大猫 9497cd65fc fix(sftp): focus the safe conflict action on open (#3531) 2026-09-25 15:37:48 +08:00
95a0186d90 fix(vault): fall back to GB18030 for non-UTF-8 CSV imports (#3517)
* fix(#3515): automated AI fix

* fix(vault): limit legacy CSV encoding fallback to intended imports

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 15:27:24 +08:00
bincxz 105b7f16bc fix(sync): carry deletion records into later uploads 2026-09-24 17:14:24 +08:00
bincxz ec11989d52 fix(sync): reconcile remote deletion records in no-op base 2026-09-24 17:10:43 +08:00
bincxz fd3e82386a fix(sync): retain deletion records across no-op providers 2026-09-24 17:04:17 +08:00
bincxz 2b120c0d41 fix(sync): preserve discovered provider identity during no-op 2026-09-24 16:57:22 +08:00
bincxz 318577cd72 fix(sync): avoid version ping-pong for identical providers 2026-09-24 16:52:16 +08:00
netcatty-bot 6398360854 fix: address Codex review on PR #3522 2026-09-24 08:32:45 +00:00
netcatty-bot e3b2e82f88 fix: address Codex review on PR #3522 2026-09-24 07:58:54 +00:00
netcatty-bot 9e07e9e7b9 fix(#3519): automated AI fix 2026-09-24 07:35:54 +00:00
119 changed files with 4209 additions and 739 deletions

No files matched your search

@@ -28,35 +28,3 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
});
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
const makeRoot = () => {
const values = new Map<string, string>();
return { style: {
getPropertyValue: (key: string) => values.get(key) ?? '',
setProperty: (key: string, value: string) => values.set(key, value),
removeProperty: (key: string) => values.delete(key),
}};
};
const tabs = makeRoot();
const titlebar = makeRoot();
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
}});
try {
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
assert.notEqual(tabs.style.getPropertyValue(property), '');
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
}
clearTopTabsChromeThemeVars();
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
} finally {
if (original) Object.defineProperty(globalThis, 'document', original);
else Reflect.deleteProperty(globalThis, 'document');
}
});
+20 -30
View File
@@ -70,31 +70,23 @@ const TOP_TABS_THEME_PROPERTIES = [
'--muted-foreground',
] as const;
export function getTopTabsChromeRoots(): HTMLElement[] {
if (typeof document === 'undefined') return [];
return [
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
].filter((root): root is HTMLElement => root !== null);
}
let topTabsChromeThemeVarsApplied = false;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
if (!topTabsChromeThemeVarsApplied) return;
for (const root of getTopTabsChromeRoots()) {
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(root, property);
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const roots = getTopTabsChromeRoots();
if (roots.length === 0) return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
@@ -105,21 +97,19 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
for (const tabsRoot of roots) {
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
}
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
+2
View File
@@ -4,6 +4,7 @@ export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
'ai.chat.compactingContext': 'Compacting earlier context…',
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
@@ -600,6 +601,7 @@ export const enAiMessages: Messages = {
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.movePanelBottom': 'Move panel to bottom',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
-3
View File
@@ -359,9 +359,6 @@ export const enCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.appearance.tabBarPosition': 'Tab bar position',
'settings.appearance.tabBarPosition.top': 'Top',
'settings.appearance.tabBarPosition.bottom': 'Bottom',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
+1
View File
@@ -188,6 +188,7 @@ export const enTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.selectIdentity': 'Use saved identity',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
-1
View File
@@ -417,7 +417,6 @@ export const enVaultMessages: Messages = {
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+2
View File
@@ -4,6 +4,7 @@ export const esAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
@@ -591,6 +592,7 @@ export const esAiMessages: Messages = {
'terminal.layer.aiChat': 'Chat de IA',
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
'terminal.layer.closePanel': 'Cerrar panel',
'terminal.layer.closePane': 'Cerrar división',
'terminal.layer.resizeSplit': 'Redimensionar división',
-3
View File
@@ -274,9 +274,6 @@ export const esCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
'settings.appearance.tabBarPosition.top': 'Arriba',
'settings.appearance.tabBarPosition.bottom': 'Abajo',
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
+1
View File
@@ -188,6 +188,7 @@ export const esTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
'terminal.auth.selectIdentity': 'Usar identidad guardada',
'terminal.auth.continueSave': 'Continuar y guardar',
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
+2
View File
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
@@ -559,6 +560,7 @@ export const ruAiMessages: Messages = {
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.closePane': 'Закрыть область',
'terminal.layer.resizeSplit': 'Изменить размер области',
-3
View File
@@ -343,9 +343,6 @@ export const ruCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
'settings.appearance.tabBarPosition.top': 'Сверху',
'settings.appearance.tabBarPosition.bottom': 'Снизу',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
+1
View File
@@ -209,6 +209,7 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
+2
View File
@@ -4,6 +4,7 @@ export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
@@ -600,6 +601,7 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
-4
View File
@@ -363,9 +363,6 @@ export const zhCNCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.appearance.tabBarPosition': '标签栏位置',
'settings.appearance.tabBarPosition.top': '顶部',
'settings.appearance.tabBarPosition.bottom': '底部',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
@@ -1061,7 +1058,6 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
+1
View File
@@ -579,6 +579,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.selectIdentity': '使用已保存的身份',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
+2
View File
@@ -4,6 +4,7 @@ export const zhTWAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
@@ -590,6 +591,7 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
-4
View File
@@ -365,9 +365,6 @@ export const zhTWCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
'settings.appearance.tabBarPosition': '分頁列位置',
'settings.appearance.tabBarPosition.top': '頂部',
'settings.appearance.tabBarPosition.bottom': '底部',
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
@@ -1063,7 +1060,6 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
+1
View File
@@ -579,6 +579,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.selectIdentity': '使用已儲存的身分',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
-3
View File
@@ -23,7 +23,6 @@ export type SettingsChromeSnapshot = {
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
tabBarPosition: 'top' | 'bottom';
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
@@ -58,7 +57,6 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
tabBarPosition: 'top',
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
@@ -86,7 +84,6 @@ export function settingsChromeSnapshotsEqual(
&& a.darkUiThemeId === b.darkUiThemeId
&& a.uiLanguage === b.uiLanguage
&& a.windowOpacity === b.windowOpacity
&& a.tabBarPosition === b.tabBarPosition
&& a.showSftpTab === b.showSftpTab
&& a.showHostTreeSidebar === b.showHostTreeSidebar
&& a.showRecentHosts === b.showRecentHosts
-8
View File
@@ -1,4 +1,3 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useEffect, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
@@ -45,7 +44,6 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
@@ -104,7 +102,6 @@ interface UseSettingsIpcSyncParams {
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
@@ -153,7 +150,6 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
@@ -310,9 +306,6 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
setTabBarPositionState(normalizeTabBarPosition(value));
}
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
@@ -379,7 +372,6 @@ export function useSettingsIpcSync({
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
+5 -12
View File
@@ -1,4 +1,3 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
@@ -35,7 +34,6 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
@@ -94,7 +92,6 @@ interface UseSettingsStorageSyncParams {
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
tabBarPosition: TabBarPosition;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
@@ -146,7 +143,6 @@ interface UseSettingsStorageSyncParams {
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
@@ -186,7 +182,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
@@ -195,7 +191,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -210,7 +206,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -220,7 +216,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -441,9 +437,6 @@ export function useSettingsStorageSync({
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
@@ -601,7 +594,7 @@ export function useSettingsStorageSync({
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setShowRecentHostsState,
setTabBarPositionState, setShowSftpTabState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let count = 0;
let options = {
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
path: '/browsed', connected: true, refresh: async () => { count += 1; },
};
function Probe() { useSftpCommandRefresh(options); return null; }
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
const update = async (patch: Partial<typeof options>) => {
options = { ...options, ...patch };
await act(async () => { renderer.update(React.createElement(Probe)); });
};
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
publishTerminalCommandCompletion('another-terminal');
await finish();
assert.equal(count, 0);
publishTerminalCommandCompletion('terminal-1');
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
await update(patch);
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
}
await update({ busy: true });
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ busy: false });
await finish();
assert.equal(count, 2);
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
publishTerminalCommandCompletion(options.sessionId);
await update(patch);
await finish();
assert.equal(count, 2);
}
publishTerminalCommandCompletion(options.sessionId);
await act(async () => { renderer.unmount(); });
await finish();
assert.equal(count, 2);
});
@@ -1,56 +0,0 @@
import { useEffect, useRef } from 'react';
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
interface Options {
sessionId: string | null;
enabled: boolean;
visible: boolean;
busy: boolean;
matchesTerminal: boolean;
paneId: string;
connectionId: string | null;
path: string | null;
connected: boolean;
refresh: () => Promise<void>;
}
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
export function useSftpCommandRefresh(options: Options): void {
const latest = useRef(options);
latest.current = options;
const drain = useRef(() => {});
useEffect(() => {
if (!options.enabled || !options.visible || !options.sessionId) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let refreshing = false;
let disposed = false;
const eligible = () => {
const live = latest.current;
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
&& live.sessionId === options.sessionId && live.paneId === options.paneId
&& live.connectionId === options.connectionId && live.path === options.path;
};
const schedule = () => {
if (!pending || refreshing || latest.current.busy || !eligible()) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (!eligible() || latest.current.busy) return;
pending = false;
refreshing = true;
void latest.current.refresh().catch(() => {}).finally(() => {
refreshing = false;
schedule();
});
}, 250);
};
drain.current = schedule;
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
if (sessionId !== options.sessionId || !eligible()) return;
pending = true;
schedule();
});
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
useEffect(() => { drain.current(); }, [options.busy]);
}
@@ -5,6 +5,7 @@ import {
buildSftpHostCredentials,
} from "./useSftpHostCredentials.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
...overrides,
});
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
const identities = [
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
] as Identity[];
const savedHost = host({ identityId: "root-id" });
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
});
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
assert.equal(credentials.username, "deploy");
assert.equal(credentials.password, "good-deploy");
assert.equal(credentials.hostId, savedHost.id);
assert.equal(savedHost.identityId, "root-id");
});
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
const jump = host({
id: "jump-1",
@@ -1,51 +0,0 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneActions } from './useSftpPaneActions';
import { createEmptyPane, type SftpPane } from './types';
import type { SftpFileEntry } from '../../../types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
let result = [file('kept'), file('new')];
const selection = new Set(['kept']);
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
const left = { current: { tabs: [pane], activeTabId: pane.id } };
const calls: string[] = [];
let actions: ReturnType<typeof useSftpPaneActions>;
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
};
function Probe() {
actions = useSftpPaneActions({
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
updateActiveTab: (side, updater) => update(side, pane.id, updater),
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
assert.deepEqual(calls, ['/browsed']);
assert.equal(left.current.tabs[0].selectedFiles, selection);
assert.equal(left.current.tabs[0].filter, 'ke');
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
result = [file('new')];
await actions!.refresh('left', { preserveSelection: true });
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
await act(async () => { renderer.unmount(); });
});
+6 -15
View File
@@ -27,13 +27,6 @@ import {
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
/** Retain selection identity when no selected entry disappeared. */
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
const names = new Set(files.map((file) => file.name));
const retained = new Set([...selected].filter((name) => names.has(name)));
return retained.size === selected.size ? selected : retained;
}
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -60,14 +53,13 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -187,7 +179,6 @@ export const useSftpPaneActions = ({
pane.connection.homeDir,
);
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
@@ -290,7 +281,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -378,7 +369,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -389,7 +380,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -454,7 +445,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
async (side: "left" | "right", options?: { tabId?: string }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -485,7 +476,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
@@ -1,12 +0,0 @@
type Listener = (sessionId: string) => void;
const listeners = new Set<Listener>();
/** Renderer-local fan-out of the terminal's existing completion detection. */
export function publishTerminalCommandCompletion(sessionId: string): void {
for (const listener of listeners) listener(sessionId);
}
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
@@ -2,10 +2,15 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampTerminalSidePanelHeight,
clampTerminalSidePanelWidth,
getTerminalSidePanelAvailableWidth,
getTerminalSidePanelMaxShownTools,
getTerminalSidePanelMaxWidth,
getTerminalSidePanelAvailableHeight,
getTerminalSidePanelMaxHeight,
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from './terminalSidePanelWidth.ts';
@@ -34,3 +39,17 @@ test('terminal side panel keeps usable terminal space in smaller windows', () =>
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
test('terminal side panel bottom dock clamps height against surface height', () => {
assert.equal(getTerminalSidePanelAvailableHeight(1000, 200), 800);
assert.equal(getTerminalSidePanelMaxHeight(1000), 760);
assert.equal(clampTerminalSidePanelHeight(900, 1000), 760);
assert.equal(clampTerminalSidePanelHeight(100, 1000), TERMINAL_SIDE_PANEL_MIN_HEIGHT);
assert.equal(clampTerminalSidePanelHeight(300, 1000, 500), 500);
assert.equal(clampTerminalSidePanelHeight(5000, 4000), TERMINAL_SIDE_PANEL_MAX_HEIGHT);
// A tall compose bar in the smallest window leaves less than the nominal
// panel minimum; the terminal's 240px reserve must still win.
assert.equal(getTerminalSidePanelMaxHeight(244), 4);
assert.equal(clampTerminalSidePanelHeight(560, 244), 4);
assert.equal(clampTerminalSidePanelHeight(560, 220), 0);
});
@@ -41,3 +41,37 @@ export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
),
);
}
export const TERMINAL_SIDE_PANEL_MIN_HEIGHT = 200;
export const TERMINAL_SIDE_PANEL_MAX_HEIGHT = 1200;
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT = 240;
// Shared tab toolbar rendered above the pane tree (`h-9`).
export const TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT = 36;
export function getTerminalSidePanelAvailableHeight(
terminalLayerHeight: number,
occupiedSiblingHeight: number,
): number {
return Math.max(0, terminalLayerHeight - occupiedSiblingHeight);
}
export function getTerminalSidePanelMaxHeight(availableSurfaceHeight: number): number {
const availableHeight = availableSurfaceHeight - TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT;
return Math.max(
0,
Math.min(TERMINAL_SIDE_PANEL_MAX_HEIGHT, availableHeight),
);
}
export function clampTerminalSidePanelHeight(
height: number,
availableSurfaceHeight: number,
contentMinimumHeight = TERMINAL_SIDE_PANEL_MIN_HEIGHT,
): number {
const maximumHeight = getTerminalSidePanelMaxHeight(availableSurfaceHeight);
const minimumHeight = Math.min(
maximumHeight,
Math.max(TERMINAL_SIDE_PANEL_MIN_HEIGHT, contentMinimumHeight),
);
return Math.max(minimumHeight, Math.min(maximumHeight, height));
}
@@ -29,6 +29,9 @@ for (const codexRuntime of ['sdk', 'app-server'] as const) {
const config = { ...agent, codexRuntime };
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
assert.equal(result.model, null);
// The unresolved probe must be distinguishable from "no locked model"
// so consumers can hide manual entry until it resolves (#3535).
assert.equal(result.isPending, true);
let sent = false;
const sending = result.loadModel().then(model => { sent = true; return model; });
await Promise.resolve();
@@ -38,9 +41,11 @@ for (const codexRuntime of ['sdk', 'app-server'] as const) {
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
assert.equal(await sending, 'glm-5');
assert.equal(result.model, 'glm-5');
assert.equal(result.isPending, false);
// A different agent must not inherit the previous home/model or promise.
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
assert.equal(result.model, null);
assert.equal(result.isPending, true);
assert.equal(calls.length, 2);
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
assert.equal(result.model, null);
+7 -1
View File
@@ -29,5 +29,11 @@ export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVi
});
return () => { cancelled = true; };
}, [isVisible, loadModel]);
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
return {
model: result?.loadModel === loadModel ? result.model : null,
loadModel,
// `model` is null both while the probe is pending and when no locked model
// exists; this flag lets consumers distinguish the unresolved state.
isPending: result?.loadModel !== loadModel,
};
}
@@ -201,3 +201,96 @@ test("managed sync writes the latest persisted hosts, not the pre-edit snapshot"
});
}
});
test("managed source deletion preserves the file when its contents cannot be read", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const globalAny = globalThis as Record<string, unknown>;
const saved = {
window: globalAny.window,
document: globalAny.document,
localStorage: globalAny.localStorage,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
actEnv: (globalAny as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
};
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { locks: createFakeLockManager() },
});
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
const original = "# Keep this comment\nHost outside\n HostName outside.example.com\n"
+ "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
+ "Host managed\n HostName managed.example.com\n"
+ "# END NETCATTY MANAGED\n";
let fileContent = original;
let writes = 0;
let failRead = true;
(dom.window as unknown as Record<string, unknown>).netcatty = {
readLocalFile: async () => {
if (failRead) throw new Error("EIO: simulated read failure");
return new TextEncoder().encode(fileContent).buffer;
},
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
writes += 1;
fileContent = new TextDecoder().decode(buffer);
},
};
let clearAndRemoveSource!: ReturnType<typeof useManagedSourceSync>["clearAndRemoveSource"];
function Harness() {
({ clearAndRemoveSource } = useManagedSourceSync({
hosts: [],
managedSources: [],
onUpdateManagedSources: () => {},
onReadPersistedHosts: async () => [],
}));
return null;
}
const root = createRoot(container);
try {
await act(async () => root.render(React.createElement(Harness)));
await assert.rejects(clearAndRemoveSource(managedSource), /Could not clear managed SSH config source/);
assert.equal(writes, 0);
assert.equal(fileContent, original);
failRead = false;
await clearAndRemoveSource(managedSource);
assert.equal(writes, 1);
assert.match(fileContent, /Host outside/);
assert.doesNotMatch(fileContent, /Host managed/);
} finally {
await act(async () => root.unmount());
container.remove();
dom.window.close();
for (const key of ["window", "document", "localStorage"] as const) {
if (key in globalAny) {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value: saved[key],
});
} else {
delete globalAny[key];
}
}
if (saved.navigator) {
Object.defineProperty(globalThis, "navigator", saved.navigator);
} else {
delete (globalThis as { navigator?: unknown }).navigator;
}
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: saved.actEnv,
});
}
});
+6 -12
View File
@@ -55,26 +55,20 @@ export const useManagedSourceSync = ({
);
const readExistingFileContent = useCallback(
async (filePath: string): Promise<string | null> => {
async (filePath: string): Promise<string> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) {
return null;
}
try {
const buffer = await bridge.readLocalFile(filePath);
const decoder = new TextDecoder();
return decoder.decode(buffer);
} catch {
// File might not exist yet
return null;
throw new Error("readLocalFile not available");
}
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
},
[],
);
const mergeWithExistingContent = useCallback(
(
existingContent: string | null,
existingContent: string,
managedHosts: Host[],
allHosts: Host[],
): string => {
@@ -82,7 +76,7 @@ export const useManagedSourceSync = ({
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
if (!existingContent) {
// No existing file, just wrap the managed content
// The existing file is empty; wrap the managed content.
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
}
+3 -22
View File
@@ -1,4 +1,3 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -79,7 +78,6 @@ import {
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
@@ -417,9 +415,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
);
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
@@ -1027,7 +1022,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
@@ -1222,7 +1216,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
@@ -1281,7 +1274,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
@@ -1291,7 +1284,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -1421,14 +1414,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setTabBarPosition = useCallback((value: TabBarPosition) => {
const next = normalizeTabBarPosition(value);
setTabBarPositionState(next);
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
@@ -1915,7 +1900,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
uiLanguage,
windowOpacity,
tabBarPosition,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
@@ -1944,7 +1928,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
tabBarPosition,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
@@ -2119,9 +2102,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
tabBarPosition,
showSftpTab,
setTabBarPosition,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
@@ -2201,7 +2182,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+1
View File
@@ -186,6 +186,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
id,
label,
type: key.type || "ED25519",
keySize: key.keySize,
privateKey: key.privateKey || "",
publicKey: key.publicKey,
certificate: key.certificate,
+45
View File
@@ -48,6 +48,51 @@ test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chine
assert.equal(result.hosts[0]?.label, "¡prod");
});
test("CSV import decodes legacy GB18030 Chinese group names", async () => {
const header = new TextEncoder().encode("Groups,Label,Hostname/IP,Port,Username\n");
const encodedGroup = new Uint8Array([
0xd6, 0xd0, 0xce, 0xc4, 0xd7, 0xe9, // 中文组 in GB18030
]);
const row = new TextEncoder().encode(",web,192.168.1.10,22,root\n");
const bytes = new Uint8Array(header.length + encodedGroup.length + row.length);
bytes.set(header);
bytes.set(encodedGroup, header.length);
bytes.set(row, header.length + encodedGroup.length);
const text = await readVaultImportFile(
"csv",
new File([bytes], "hosts.csv", { type: "text/csv" }),
);
const result = importVaultHostsFromText("csv", text);
assert.equal(result.hosts[0]?.group, "中文组");
});
test("CSV import keeps valid UTF-8 Chinese group names", async () => {
const text = "Groups,Label,Hostname/IP,Port,Username\n中文组,web,192.168.1.10,22,root\n";
const decoded = await readVaultImportFile(
"csv",
new File([text], "hosts.csv", { type: "text/csv" }),
);
const result = importVaultHostsFromText("csv", decoded);
assert.equal(result.hosts[0]?.group, "中文组");
});
test("ssh_config import keeps UTF-8 host names when a comment has invalid bytes", async () => {
const file = new File([
new TextEncoder().encode("# legacy comment "),
new Uint8Array([0xff]),
new TextEncoder().encode("\nHost 中文组\n HostName example.com\n"),
], "config", { type: "text/plain" });
const text = await readVaultImportFile("ssh_config", file);
const result = importVaultHostsFromText("ssh_config", text);
assert.equal(result.hosts[0]?.label, "中文组");
assert.equal(result.hosts[0]?.hostname, "example.com");
});
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
+9 -14
View File
@@ -2001,22 +2001,17 @@ test("missing local shell settings and invalid tabs preserve existing settings",
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
});
test("tab bar position survives settings export and import, with a safe fallback", async () => {
test("old bottom tab preference is ignored by settings export and import", async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
const legacyKey = "netcatty_tab_bar_position_v1";
localStorage.setItem(legacyKey, "bottom");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.tabBarPosition, "bottom");
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
await applySyncPayload(invalid, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
assert.equal(Object.hasOwn(payload.settings ?? {}, "tabBarPosition"), false);
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(legacyKey), "bottom");
localStorage.removeItem(legacyKey);
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(legacyKey), null);
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
-8
View File
@@ -1,4 +1,3 @@
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
@@ -96,7 +95,6 @@ import {
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
@@ -323,7 +321,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -557,8 +554,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
}
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
@@ -830,9 +825,6 @@ async function applySyncableSettings(
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.tabBarPosition != null) {
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
+4
View File
@@ -67,6 +67,8 @@ interface AIChatPanelContentProps {
modelDisplayName?: string;
modelCatalogWarning?: string;
agentModelPresets: AgentModelPreset[];
/** Suppress the composer picker's "use custom model" action (locked model). */
allowCustomModelEntry?: boolean;
selectedAgentModel: string;
handleAgentModelSelect: (modelId: string) => void;
cattyConfiguredProviders: ProviderConfig[];
@@ -138,6 +140,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelDisplayName,
modelCatalogWarning,
agentModelPresets,
allowCustomModelEntry = true,
selectedAgentModel,
handleAgentModelSelect,
cattyConfiguredProviders,
@@ -354,6 +357,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={providerSwitcher}
+103 -27
View File
@@ -22,6 +22,14 @@ import {
resolveAgentModelSelection,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import {
appendComposerCustomModelPresets,
resolveComposerCustomModelIds,
} from '../infrastructure/ai/composerPicker';
import {
readComposerModelPrefs,
subscribeComposerModelPrefs,
} from '../infrastructure/ai/composerModelPrefs';
import { toast } from './ui/toast';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
@@ -311,8 +319,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
@@ -843,13 +851,34 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
const {
model: codexConfigModel,
loadModel: loadCodexConfigModel,
isPending: isCodexConfigModelPending,
} = useCodexConfigModel(
currentAgentConfig, isVisible,
);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
// Manual model ids typed in the composer picker are recorded in composer
// prefs (per agent scope). Re-render when they change so custom picks stay
// visible and accepted as stored selections (#3534).
const [composerCustomModelsVersion, setComposerCustomModelsVersion] = useState(0);
useEffect(() => subscribeComposerModelPrefs(() => {
setComposerCustomModelsVersion((version) => version + 1);
}), []);
const collectCustomModelIds = useCallback((agentId: string, presets: AgentModelPreset[]) => {
// Touch the version so pref writes recreate this callback and invalidate
// memos that read custom ids.
void composerCustomModelsVersion;
return resolveComposerCustomModelIds({
prefs: readComposerModelPrefs(agentId),
presets,
});
}, [composerCustomModelsVersion]);
const buildExternalAgentRuntimeModelTarget = useCallback((agent: ExternalAgentConfig | undefined): SdkRuntimeModelTarget | null => {
if (!agent) return null;
const sdkBackend = getExternalAgentSdkBackend(agent);
@@ -874,15 +903,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
externalAgentsRef.current = externalAgents;
const applySdkRuntimeModelCatalog = useCallback((
agentId: string,
target: SdkRuntimeModelTarget,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
const runtimePresets = mergeFallbackThinkingLevels(
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
const agentId = target.agentId;
const catalogPresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
);
const runtimePresets = appendComposerCustomModelPresets(
catalogPresets,
collectCustomModelIds(agentId, catalogPresets),
);
const storedModelId = agentModelMapRef.current[agentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
@@ -892,9 +927,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
} else {
setRuntimeAgentModelPresets((prev) => (
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
prev[agentId]?.cacheKey === target.cacheKey
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
? prev
: { ...prev, [agentId]: runtimePresets }
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
));
}
@@ -914,7 +950,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
setAgentModel(agentId, catalog.currentModelId);
}
}, [setAgentModel]);
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
const loadSdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
@@ -939,10 +975,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
throw new Error(result?.error || 'Failed to load SDK agent models');
}
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
const next = { ...current };
if (result.warning && target.codexRuntime === 'app-server') {
next[target.agentId] = t('ai.codex.appServer.modelCatalogWarning');
console.warn('[AIChatSidePanel] Codex App Server model catalog unavailable:', result.warning);
if (result.warning) {
next[target.agentId] = {
cacheKey: target.cacheKey,
message: t('ai.chat.modelCatalogWarning'),
};
console.warn('[AIChatSidePanel] SDK model catalog unavailable:', result.warning);
} else {
delete next[target.agentId];
}
@@ -955,18 +996,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
},
{ force: options.force },
).catch((err) => {
if (target.codexRuntime === 'app-server') {
setRuntimeModelWarnings((current) => ({
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
return {
...current,
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
}));
}
[target.agentId]: { cacheKey: target.cacheKey, message: t('ai.chat.modelCatalogWarning') },
};
});
if (options.logErrors !== false) {
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
return null;
});
}, [t]);
}, [t, buildExternalAgentRuntimeModelTarget]);
useEffect(() => {
if (!isVisible) return;
@@ -978,7 +1021,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cached = sdkRuntimeModelCache.read(target.cacheKey);
if (cached) {
applySdkRuntimeModelCatalog(target.agentId, cached);
applySdkRuntimeModelCatalog(target, cached);
}
// Respect renderer TTL / in-flight coalescing for all SDK agents including
@@ -990,7 +1033,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cancelIdle = scheduleWhenAiComposerIdle(() => {
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
if (cancelled || !catalog) return;
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
});
});
@@ -1010,27 +1053,50 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
// `codexConfigModel` is null both while the config probe is pending and when
// the managed Codex config has no locked model. Keep the manual-entry action
// hidden until the probe resolves so a pick made in the interim can't be
// silently overridden by the config model on send (#3535).
const allowCustomModelEntry = !isCodexManagedAgent
|| (!isCodexConfigModelPending && !hasCodexCustomConfig);
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
const runtimePresets = runtimeEntry && target && runtimeEntry.cacheKey === target.cacheKey
? runtimeEntry.models
: undefined;
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) return runtimePresets;
if (runtimePresets) {
return appendComposerCustomModelPresets(
runtimePresets,
collectCustomModelIds(currentAgentId, runtimePresets),
);
}
const presets = getAgentModelPresets(
currentAgentConfig?.command,
getExternalAgentSdkBackend(currentAgentConfig),
);
// BYO Codex CLI: hide GPT-5.6 when CLI < 0.144.0 (stored probe or discovery).
const cliVersion = resolveAgentCliVersion(currentAgentConfig, discoveredAgents);
return filterAgentModelPresetsForCliVersion(presets, cliVersion);
const filteredPresets = filterAgentModelPresetsForCliVersion(presets, cliVersion);
// Manual model ids typed in the composer (#3534) stay selectable even when
// the CLI catalog does not list them yet.
return appendComposerCustomModelPresets(
filteredPresets,
collectCustomModelIds(currentAgentId, filteredPresets),
);
}, [
currentAgentConfig,
currentAgentId,
runtimeAgentModelPresets,
buildExternalAgentRuntimeModelTarget,
hasCodexCustomConfig,
codexConfigModel,
discoveredAgents,
collectCustomModelIds,
]);
const selectedAgentModel = useMemo(() => {
@@ -1213,14 +1279,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
if (catalog) {
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
const runtimePresets = mergeFallbackThinkingLevels(
applySdkRuntimeModelCatalog(runtimeTarget, catalog, { adoptCurrentModel: true });
const catalogPresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(
currentAgentConfig.command,
getExternalAgentSdkBackend(currentAgentConfig),
),
);
const runtimePresets = appendComposerCustomModelPresets(
catalogPresets,
collectCustomModelIds(sendAgentId, catalogPresets),
);
const storedModelId = agentModelMapRef.current[sendAgentId];
if (
catalog.currentModelId
@@ -1403,7 +1473,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog,
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog, collectCustomModelIds,
]);
const handleCompact = useCallback(async () => {
@@ -1720,8 +1790,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
? runtimeModelWarnings[currentAgentId].message
: undefined}
agentModelPresets={agentModelPresets}
// A managed Codex config's `model` field overrides every selection on
// send, so the manual-entry action would be a silent no-op.
allowCustomModelEntry={allowCustomModelEntry}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
+28 -1
View File
@@ -89,6 +89,11 @@ test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () =>
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
const unsupported = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: [] }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }],
);
assert.deepEqual(unsupported[0]?.thinkingLevels, []);
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
assert.equal(
@@ -137,7 +142,7 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), true);
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
});
@@ -156,6 +161,28 @@ test('Codex App Server model discovery uses a separate cache identity', () => {
codexRuntime: 'app-server',
});
assert.notEqual(sdk, appServer);
assert.notEqual(
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/a' } }),
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/b' } }),
);
});
test('Claude model cache keys isolate configuration directories under one home', () => {
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
assert.notEqual(
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/a' } }),
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/b' } }),
);
});
test('Claude model cache keys isolate credentials without exposing them', () => {
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
for (const name of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN']) {
const first = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-one' } });
const second = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-two' } });
assert.notEqual(first, second, `${name} must separate cache entries`);
assert.doesNotMatch(first, /secret-one/);
}
});
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
+25 -18
View File
@@ -4,9 +4,19 @@ import {
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
import {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
} from '../infrastructure/ai/composerPicker';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
export { canonicalizeEffortEncodedModelId };
export {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
};
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
@@ -35,6 +45,11 @@ const MODEL_CACHE_ENV_HINTS = [
'HOME',
'USERPROFILE',
'XDG_CONFIG_HOME',
'CODEX_HOME',
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'OPENCODE_BIN',
'OPENCODE_CONFIG',
'OPENCODE_CONFIG_DIR',
@@ -103,7 +118,9 @@ export function buildSdkRuntimeModelCacheKey(agent: {
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
// Keep authentication values out of the in-memory cache key itself.
const envHash = bytesToHex(sha256(new TextEncoder().encode(envHints.join('\u0000'))));
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, envHash].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
@@ -190,7 +207,7 @@ export function mergeFallbackThinkingLevels(
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels?.length) return preset;
if (preset.thinkingLevels !== undefined) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
@@ -225,19 +242,6 @@ export function agentModelPresetsShallowEqual(
});
}
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
const canonical = canonicalizeEffortEncodedModelId(modelId);
if (preset.thinkingLevels?.length) {
return preset.id === canonical
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
}
return preset.id === canonical;
}
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function normalizeStoredAgentModelSelection(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
@@ -253,7 +257,10 @@ export function normalizeStoredAgentModelSelection(
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
// Codex on the default `sdk` runtime also serves a live catalog: its driver
// falls back to the App Server runtime's model/list in main (#3496), so
// quick sends must await it instead of running with build-time presets.
return sdkBackend === 'codex'
|| sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
+10 -13
View File
@@ -17,6 +17,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import type { GroupConfig } from "../domain/models";
import { detectSshKeyType } from "../domain/keyTypeDetect";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
@@ -422,16 +423,16 @@ echo $3 >> "$FILE"`);
}
// Detect key type from private key content
let detectedType: KeyType = "ED25519";
const pk = draftKey.privateKey.toLowerCase();
if (pk.includes("rsa")) detectedType = "RSA";
else if (pk.includes("ecdsa") || pk.includes("ec ")) detectedType = "ECDSA";
else if (pk.includes("ed25519")) detectedType = "ED25519";
const detected = detectSshKeyType(
draftKey.privateKey,
draftKey.publicKey,
);
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: (draftKey.type as KeyType) || detectedType,
type: detected.type,
keySize: detected.keySize,
privateKey: draftKey.privateKey.trim(),
publicKey: draftKey.publicKey?.trim() || undefined,
certificate: draftKey.certificate?.trim() || undefined,
@@ -541,12 +542,7 @@ echo $3 >> "$FILE"`);
const content = e.target?.result as string;
if (content) {
// Try to detect key type from content
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
const detected = detectSshKeyType(content);
// Extract label from filename (remove extension)
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
@@ -555,7 +551,8 @@ echo $3 >> "$FILE"`);
...prev,
privateKey: content,
label: prev.label || label,
type: detectedType,
type: detected.type,
keySize: detected.keySize,
}));
}
};
-2
View File
@@ -557,8 +557,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setHostClickBehavior={settings.setHostClickBehavior}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
tabBarPosition={settings.tabBarPosition}
setTabBarPosition={settings.setTabBarPosition}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
-21
View File
@@ -1,4 +1,3 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -1625,22 +1624,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeExternalEditCount ?? 0) > 0;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
const [autoRefresh, setAutoRefresh] = useState(true);
useSftpCommandRefresh({
sessionId: focusedSessionId ?? activeSessionId,
enabled: autoRefresh,
visible: isVisible && ownerPanelOpen,
busy: hasActiveWork || sftp.leftPane.loading,
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
&& sftp.leftPane.connection?.hostId === activeHost.id,
paneId: sftp.leftPane.id,
connectionId,
path: connectionPath,
connected: sftp.leftPane.connection?.status === "connected",
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
});
const {
handleGoToTerminalCwd,
@@ -1882,10 +1865,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
{t("sftp.refreshAfterCommand")}
</label>
{showWorkspaceHostHeader && displayHost && (
<div
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
+24 -11
View File
@@ -1,5 +1,4 @@
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -82,6 +81,7 @@ import { usePluginTerminalProviders } from "../application/state/usePluginTermin
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
import { resolvePasswordAuthSftpHost } from "../domain/authIdentityPicker";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
@@ -1238,6 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
const auth = useTerminalAuthState({
host,
identities,
pendingAuthRef,
termRef,
onUpdateHost: handleUpdateHostFromTerminal,
@@ -1438,8 +1439,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
onStatusChange?.(sessionId, next);
}, [clearAutoReconnect, onStatusChange, sessionId]);
const sftpHost = next === "connected"
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
onStatusChange?.(sessionId, next, sftpHost === host ? undefined : sftpHost);
}, [clearAutoReconnect, host, identities, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
@@ -2380,10 +2384,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle, sessionId]);
}, [pluginTerminalLifecycle]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -3467,8 +3470,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
const initialPath = await resolveSftpInitialPath();
const sftpHost = statusRef.current === 'connected'
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
onOpenSftp(
host,
sftpHost,
initialPath,
undefined,
sessionId,
@@ -3483,7 +3489,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setShowSFTP(true);
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
}, [host, identities, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
const handleSendYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
@@ -3961,6 +3967,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
});
const dragDropSftpHost = status === 'connected'
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
const dragDropSudoPassword = dragDropSftpHost === host
? resolvedSudoAutofillPassword
: identities.find(identity => identity.id === dragDropSftpHost.identityId)?.password;
const {
handleDragEnter,
handleDragLeave,
@@ -3968,9 +3981,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleDrop,
isDraggingOver,
} = useTerminalDragDrop({
host,
resolvedLoginUsername,
resolvedSudoPassword: resolvedSudoAutofillPassword,
host: dragDropSftpHost,
resolvedLoginUsername: dragDropSftpHost === host ? resolvedLoginUsername : dragDropSftpHost.username,
resolvedSudoPassword: dragDropSudoPassword,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
@@ -4545,7 +4558,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+101 -18
View File
@@ -20,6 +20,7 @@ import {
} from '../application/state/codingCliSessionSignalController';
import { collectSessionIds } from '../domain/workspace';
import { isPluginHostProtocol } from '../domain/pluginConnection';
import { isSameSftpHostSelection } from '../domain/sftpTerminalIdentity';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
@@ -27,6 +28,7 @@ import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { useStoredBoolean } from '../application/state/useStoredBoolean';
import {
STORAGE_KEY_SIDE_PANEL_HEIGHT,
STORAGE_KEY_SIDE_PANEL_WIDTH,
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
} from '../infrastructure/config/storageKeys';
@@ -131,9 +133,12 @@ import {
} from '../domain/paneMagnification';
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
import {
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from '../application/state/terminalSidePanelWidth';
import { isSidePanelDockPosition } from '../domain/sidePanelLayout';
import {
AIChatPanelsHost,
@@ -464,7 +469,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
useWorkspaceLayoutPresetState();
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string, sftpHostOverride?: Host) => {
// Preset persistence and resolution live in the application layer; the
// component only mounts the resolved panes into its UI state.
const resolved = resolveDefaultLayoutForSession(session);
@@ -497,6 +502,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpOpeningTabIdsRef.current.add(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, session.id));
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
@@ -516,7 +522,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
} as Host;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
next.set(tabId, sftpHostOverride ?? hostWithOverrides);
return next;
});
}
@@ -545,11 +551,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return true;
}, [persistWorkspaceLayoutAsDefault, t]);
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status'], sftpHostOverride?: Host) => {
onUpdateSessionStatus(sessionId, status);
if (status !== 'connected') return;
setSftpAuthHostBySessionId(prev => {
const next = new Map(prev);
if (sftpHostOverride) next.set(sessionId, sftpHostOverride);
else next.delete(sessionId);
return next;
});
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
const workspaceId = session.workspaceId;
@@ -569,6 +582,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
const proto = presetSession.protocol ?? 'ssh';
if (presetSession.id === sessionId && (sftpHostOverride || sftpHostSourceSessionForTabRef.current.get(tabId) === sessionId)) {
const connectedHost = sftpHostOverride ?? sessionHostsMapRef.current.get(sessionId);
if (connectedHost) {
setSftpHostForTab(prev => new Map(prev).set(tabId, connectedHost));
}
setSftpHostSourceSessionForTab(prev => {
if (!sftpHostOverride) return prev;
const next = new Map(prev);
next.set(tabId, sessionId);
return next;
});
}
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const targetPanel = resolveSessionSidePanelAutoOpen({
@@ -584,7 +610,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// saved default that may omit file transfer — only when SFTP is actually
// available. Ordinary auto-open still yields to the saved default.
if (targetPanel !== "sftp") {
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
if (applyWorkspaceLayoutPresetForSession(
presetSession,
tabId,
presetSession.id === sessionId ? sftpHostOverride : undefined,
)) return;
if (!targetPanel) return;
}
@@ -592,6 +622,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, presetSession.id));
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
const hostWithOverrides: Host = host
? {
@@ -612,7 +643,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
next.set(tabId, presetSession.id === sessionId && sftpHostOverride ? sftpHostOverride : hostWithOverrides);
return next;
});
} else if (targetPanel === 'ai') {
@@ -732,10 +763,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
420,
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right' | 'bottom'>(
'netcatty_side_panel_position',
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
isSidePanelDockPosition,
);
const [sidePanelHeight, setSidePanelHeight, persistSidePanelHeight] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_HEIGHT,
360,
{ min: TERMINAL_SIDE_PANEL_MIN_HEIGHT, max: TERMINAL_SIDE_PANEL_MAX_HEIGHT },
);
// 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.
@@ -744,6 +780,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpHostSourceSessionForTab, setSftpHostSourceSessionForTab] = useState<Map<string, string>>(new Map());
const [sftpAuthHostBySessionId, setSftpAuthHostBySessionId] = useState<Map<string, Host>>(new Map());
useEffect(() => {
setSftpAuthHostBySessionId(prev => {
const liveIds = new Set(sessions.map(session => session.id));
const next = new Map([...prev].filter(([id]) => liveIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [sessions]);
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
Map<string, { hostId: string; path: string }>
>(new Map());
@@ -756,6 +801,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const notesOpenRequestIdRef = useRef(0);
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const sftpHostSourceSessionForTabRef = useRef(sftpHostSourceSessionForTab);
sftpHostSourceSessionForTabRef.current = sftpHostSourceSessionForTab;
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
@@ -803,6 +850,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(tabId);
return next;
});
setSftpHostSourceSessionForTab(prev => {
if (!prev.has(tabId)) return prev;
const next = new Map(prev);
next.delete(tabId);
return next;
});
setSftpPendingUploadsForTab(prev => {
if (!prev.has(tabId)) return prev;
const next = new Map(prev);
@@ -988,14 +1041,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Compare full endpoint identity so that session-time overrides
// (different port/protocol for the same host ID) trigger a switch
// instead of toggling the panel closed.
const isSameEndpoint = currentHost
&& currentHost.id === host.id
&& currentHost.hostname === host.hostname
&& currentHost.port === host.port
&& currentHost.protocol === host.protocol
&& currentHost.username === host.username
&& currentHost.sftpSudo === host.sftpSudo
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
const isSameEndpoint = isSameSftpHostSelection(currentHost, host);
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
const paneCount = currentLayout
@@ -1032,6 +1078,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, host);
return next;
});
setSftpHostSourceSessionForTab(prev => {
const next = new Map(prev);
if (originSessionId) next.set(tabId, originSessionId);
else next.delete(tabId);
return next;
});
if (originSessionId) {
const sessionHost = sessionHostsMapRef.current.get(originSessionId);
if (sessionHost && (host.username !== sessionHost.username || host.identityId !== sessionHost.identityId)) {
setSftpAuthHostBySessionId(prev => new Map(prev).set(originSessionId, host));
}
}
const { connectionKey, effectiveInitialPath } = resolveSftpOpenTarget({
tabId,
@@ -1566,16 +1624,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpPaneClosedTabIdsRef.current.delete(tabId);
}
if (tab === 'sftp') {
const sourceSessionId = getActiveTerminalSessionId();
const sessionHost = sourceSessionId ? sessionHostsMapRef.current.get(sourceSessionId) : undefined;
const authHost = sourceSessionId ? sftpAuthHostBySessionId.get(sourceSessionId) : undefined;
const storedHost = sftpHostForTabRef.current.get(tabId);
if (sourceSessionId && sessionHost && authHost
&& (!storedHost || sftpHostSourceSessionForTabRef.current.has(tabId)
|| (storedHost.id === sessionHost.id
&& storedHost.hostname === sessionHost.hostname
&& (storedHost.port || 22) === (sessionHost.port || 22)))) {
setSftpHostForTab(prev => new Map(prev).set(tabId, authHost));
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
}
}
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
const host = resolveSftpHostForTab(tabId);
const sourceSessionId = getActiveTerminalSessionId();
const host = (sourceSessionId && sftpAuthHostBySessionId.get(sourceSessionId))
?? resolveSftpHostForTab(tabId);
if (!host) return;
if (sourceSessionId) {
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
}
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, host);
return next;
});
const sourceSessionId = getActiveTerminalSessionId();
const { effectiveInitialPath } = resolveSftpOpenTarget({
tabId,
host,
@@ -1597,7 +1674,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// explicitly closes the now-idle panel, preserving the completed history.
markSidePanelSubTabOpened(tabId, tab);
return true;
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget, sftpAuthHostBySessionId]);
// Switch only the focused pane. If the tool is already open elsewhere in
// the tree, the domain helper focuses that pane instead of duplicating it.
@@ -2418,6 +2495,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
persistSidePanelHeight,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleRunScriptFromPanel,
@@ -2531,8 +2609,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSidePanelOpenTabs,
setSidePanelLayouts,
setSidePanelWidth,
setSidePanelHeight,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab,
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
setSftpPendingUploadsForTab,
@@ -2541,11 +2621,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sidePanelLayouts,
sidePanelPosition,
sidePanelWidth,
sidePanelHeight,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpHostSourceSessionForTab,
sftpAuthHostBySessionId,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
+12 -24
View File
@@ -228,7 +228,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
tabBarPosition,
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
@@ -270,8 +269,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
const [rootTabsCompact, setRootTabsCompact] = useState(false);
const tabsAtBottom = tabBarPosition === 'bottom';
const showWindowControls = !isMacClient && !tabsAtBottom;
const showWindowControls = !isMacClient;
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
@@ -999,24 +997,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}, [isMacClient, maximize]);
return (
<>
<div
data-tab-window-titlebar
data-section="top-tabs"
className={cn("h-9 shrink-0 items-end justify-end bg-secondary app-drag", tabsAtBottom ? "flex" : "hidden")}
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{!isMacClient && <WindowControls />}
</div>
<div
data-top-tabs-root
data-position={tabBarPosition}
data-section="top-tabs"
className="relative w-full shrink-0 bg-secondary app-drag"
className="relative w-full bg-secondary app-drag"
style={{
...dragRegionNoSelect,
order: tabsAtBottom ? 2 : undefined,
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
}}
@@ -1027,12 +1013,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
updateScrollState={updateScrollState}
/>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className={cn("absolute inset-x-0 h-1 app-drag pointer-events-auto z-10", tabsAtBottom ? "bottom-0" : "top-0")} style={dragRegionStyle} aria-hidden />
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
className="h-9 flex items-end gap-0 app-drag overflow-visible"
style={{
...dragRegionStyle,
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
paddingRight: showWindowControls ? 0 : 12,
}}
>
@@ -1080,7 +1066,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{hasHostTreeToggleSurface && (
<div
ref={hostTreeToggleSlotRef}
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
data-section="top-tabs-host-tree-toggle"
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
style={noDragRegionStyle}
@@ -1190,7 +1176,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
>
@@ -1202,9 +1188,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</div>
)}
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
{/* Keep window controls above the top drag stripe so their full height remains clickable. */}
<div
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
className={cn(
"flex-shrink-0 flex items-center gap-0.5 app-drag overflow-visible self-end",
showWindowControls ? "relative z-20 h-9" : "h-7",
)}
style={dragRegionStyle}
data-section="top-tabs-toolbar-actions"
>
@@ -1279,7 +1268,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
</div>
</div>
</>
);
};
+14 -3
View File
@@ -206,6 +206,12 @@ interface ChatInputProps {
placeholder?: string;
/** Available model presets for the current agent */
modelPresets?: AgentModelPreset[];
/**
* Offer the picker's "use custom model" action. Disable when the current
* agent locks the model (e.g. managed Codex `model` config overrides the
* selection on every send).
*/
allowCustomModelEntry?: boolean;
/** Currently selected model ID */
selectedModelId?: string;
/** Callback when user selects a model */
@@ -271,6 +277,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
agentName,
placeholder,
modelPresets = [],
allowCustomModelEntry = true,
selectedModelId,
onModelSelect,
files = [],
@@ -1526,7 +1533,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
) : (
<Cpu size={11} className="text-muted-foreground/64" />
)}
<span className={`truncate min-w-0 ${modelChipMaxWidth}`}>{modelLabel}</span>
<span className={`truncate min-w-0 ${modelChipMaxWidth}`} title={modelLabel}>{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{contextUsage && (
@@ -1585,19 +1592,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
selectedProviderId={providerSwitcher?.selectedProviderId}
selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId}
modelPresets={hasProviderSwitcher ? undefined : modelPresets}
allowCustomEntry={allowCustomModelEntry}
prefs={modelPrefs}
onSelectProviderModel={(providerId, modelId, contextWindow) => {
providerSwitcher?.onSelect(providerId, modelId, contextWindow);
setModelPrefs(rememberComposerRecentModel(pickerScope, { providerId, modelId }));
closeAllMenus();
}}
onSelectModel={(modelId) => {
onSelectModel={(modelId, options) => {
const preset = modelPresets.find((item) => item.id === modelId);
const nextId = preset
? resolveModelSelectionWithThinking(preset, selectedPresetThinking)
: modelId;
onModelSelect?.(nextId);
setModelPrefs(rememberComposerRecentModel(pickerScope, { modelId }));
setModelPrefs(rememberComposerRecentModel(pickerScope, options?.custom
? { modelId, custom: true }
: { modelId }));
closeAllMenus();
}}
onTogglePinned={(entry) => {
@@ -1795,6 +1805,7 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.agentName === next.agentName
&& prev.placeholder === next.placeholder
&& prev.modelPresets === next.modelPresets
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
&& prev.selectedModelId === next.selectedModelId
&& prev.onModelSelect === next.onModelSelect
&& prev.files === next.files
+45 -2
View File
@@ -61,8 +61,51 @@ test('external agent picker lists presets without a provider column', () => {
assert.doesNotMatch(html, /ai\.chat\.providers/);
});
test('custom model action is only offered in Catty provider-switcher mode', () => {
test('custom model action is offered in provider-switcher and preset modes', () => {
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
assert.match(source, /const showCustom = Boolean\(\s*hasProviders/s);
// Custom ids are accepted regardless of mode (provider catalog or CLI
// presets) — #3534 wants manual model entry for external agents too.
assert.match(source, /const showCustom = Boolean\(\s*allowCustomEntry\s*&&\s*trimmedQuery/s);
assert.match(source, /resolveComposerEnterModelId/);
});
test('custom model action can be suppressed when the host locks the model', () => {
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
// Codex config-locked models override every selection on send; the host
// turns the action off so it never becomes a silent no-op.
assert.match(source, /allowCustomEntry = true/);
const html = renderToStaticMarkup(
<ComposerModelPicker
modelPresets={[{ id: 'gpt-5.5', name: 'GPT-5.5' }]}
selectedModelId="gpt-5.5"
prefs={{ recent: [], pinned: [] }}
allowCustomEntry={false}
onSelectModel={() => {}}
onTogglePinned={() => {}}
/>,
);
assert.doesNotMatch(html, /ai\.chat\.useCustomModel/);
});
test('reselecting a saved custom model preserves its custom provenance', () => {
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
// A saved custom id is appended to the preset list, so picking it from
// Recent has showCustom === false. The callback must keep the custom flag
// anyway, or the host overwrites the pref entry without it and
// resolveComposerCustomModelIds drops the model from the presets.
assert.match(source, /customPrefIds\.has\(modelId\.toLowerCase\(\)\)/);
});
test('external agent picker keeps preset rows truncation-friendly with a full-name tooltip', () => {
const html = renderToStaticMarkup(
<ComposerModelPicker
modelPresets={[{ id: 'gpt-5.6-sol-very-long-preview-id', name: 'GPT-5.6 Sol (preview)' }]}
selectedModelId="gpt-5.6-sol-very-long-preview-id"
prefs={{ recent: [], pinned: [] }}
onSelectModel={() => {}}
onTogglePinned={() => {}}
/>,
);
assert.match(html, /title="GPT-5\.6 Sol \(preview\)"/);
});
+41 -5
View File
@@ -2,6 +2,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lu
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
canonicalizeEffortEncodedModelId,
filterComposerModels,
resolveComposerEnterModelId,
resolvePinnedAndRecentModels,
@@ -22,8 +23,14 @@ export interface ComposerModelPickerProps {
selectedModelId?: string;
modelPresets?: AgentModelPreset[];
prefs: ComposerModelPrefs;
/**
* Offer the "use custom model" action. Disable when the host locks the
* model (e.g. a managed Codex config's `model` field overrides every
* selection), so the action never becomes a silent no-op.
*/
allowCustomEntry?: boolean;
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
onSelectModel?: (modelId: string) => void;
onSelectModel?: (modelId: string, options?: { custom?: boolean }) => void;
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
}
@@ -56,7 +63,7 @@ const ModelRow: React.FC<{
{selected
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />}
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
<span className="min-w-0 flex-1 truncate text-foreground/88" title={model.name}>{model.name}</span>
</button>
<button
type="button"
@@ -83,6 +90,7 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
selectedModelId,
modelPresets = [],
prefs,
allowCustomEntry = true,
onSelectProviderModel,
onSelectModel,
onTogglePinned,
@@ -133,23 +141,51 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
);
const trimmedQuery = query.trim();
// Manual model ids are accepted in both modes: provider-bound catalogs and
// preset lists from external CLI agents whose catalogs lag new models.
const showCustom = Boolean(
hasProviders
allowCustomEntry
&& trimmedQuery
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
);
// Ids the prefs already mark as custom. A saved custom model that was
// appended to the preset list is selectable from Recent/Pinned while
// showCustom is false (query empty, id present in the list); the callback
// must keep the custom provenance or the host overwrites the pref entry
// without it and resolveComposerCustomModelIds drops the model (#3534).
const customPrefIds = useMemo(() => {
const ids = new Set<string>();
for (const entry of [...prefs.pinned, ...prefs.recent]) {
if (entry.providerId || entry.custom !== true) continue;
ids.add(canonicalizeEffortEncodedModelId(entry.modelId).trim().toLowerCase());
}
return ids;
}, [prefs]);
const selectModel = (modelId: string) => {
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
if (hasProviders && previewProvider) {
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
return;
}
onSelectModel?.(modelId);
// showCustom is only true when no catalog id matches the query, so a
// matching pick here came from the manual-entry action (row or Enter).
// Otherwise preserve provenance already recorded in the prefs.
const custom = Boolean(
(showCustom && modelId.toLowerCase() === trimmedQuery.toLowerCase())
|| customPrefIds.has(modelId.toLowerCase()),
);
onSelectModel?.(modelId, custom ? { custom: true } : undefined);
};
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
previewProvider
? { providerId: previewProvider.id, modelId }
// Preserve custom provenance on pin/unpin: a manual model pinned while
// other selections evict its unmarked recent twin would otherwise lose
// the custom marker and fall back to a catalog model (#3534).
: { modelId, ...(customPrefIds.has(modelId.toLowerCase()) ? { custom: true as const } : {}) }
);
if (hasProviders && view === 'providers') {
+7 -5
View File
@@ -10,7 +10,7 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { detectKeyType } from './utils';
import { detectSshKeyType } from '../../domain/keyTypeDetect';
interface ImportKeyPanelProps {
draftKey: Partial<SSHKey>;
@@ -38,14 +38,15 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detectedType = detectKeyType(content);
const detected = detectSshKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detectedType,
type: detected.type,
keySize: detected.keySize,
});
}
};
@@ -64,14 +65,15 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detectedType = detectKeyType(content);
const detected = detectSshKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detectedType,
type: detected.type,
keySize: detected.keySize,
});
}
};
+2 -4
View File
@@ -5,6 +5,7 @@
import { BadgeCheck, Key } from 'lucide-react';
import React from 'react';
import { logger } from '../../lib/logger';
import { detectSshKeyType } from '../../domain/keyTypeDetect';
import { KeyType, SSHKey } from '../../types';
/**
@@ -27,10 +28,7 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
* Detect key type from private key content
*/
export const detectKeyType = (privateKey: string): KeyType => {
const pk = privateKey.toLowerCase();
if (pk.includes('rsa')) return 'RSA';
if (pk.includes('ecdsa') || pk.includes('ec ')) return 'ECDSA';
return 'ED25519';
return detectSshKeyType(privateKey).type;
};
/**
@@ -60,8 +60,6 @@ function SettingsAppearanceTab(props: {
setHostClickBehavior: (behavior: "connect" | "select") => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
tabBarPosition: 'top' | 'bottom';
setTabBarPosition: (position: 'top' | 'bottom') => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
showHostTreeSidebar: boolean;
@@ -131,8 +129,6 @@ function SettingsAppearanceTab(props: {
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
tabBarPosition,
setTabBarPosition,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
@@ -483,29 +479,6 @@ function SettingsAppearanceTab(props: {
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
anchorId="appearance-tab-bar-position"
label={t('settings.appearance.tabBarPosition')}
>
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{(['top', 'bottom'] as const).map((position) => (
<button
key={position}
type="button"
aria-pressed={tabBarPosition === position}
onClick={() => setTabBarPosition(position)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
tabBarPosition === position
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`settings.appearance.tabBarPosition.${position}`)}
</button>
))}
</div>
</SettingRow>
<SettingRow
anchorId="appearance-vault-show-sftp-tab"
label={t('settings.vault.showSftpTab')}
@@ -12,13 +12,14 @@ const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('refresh keeps scroll, while explicit sorting and filters reveal the selection', async (t) => {
test('reconnect listing update keeps scroll, while sorting and filters reveal the selection', async (t) => {
const dom = installDomEnvironment();
t.after(() => dom.cleanup());
let scrolls = 0;
const container = { querySelectorAll: () => [{ dataset: { entryName: 'selected' }, scrollIntoView: () => { scrolls += 1; } }] };
const noop = () => {};
const selected = { name: 'selected', type: 'file' as const, size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '' };
const added = { ...selected, name: 'added' };
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key, pane: { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', isLocal: false, status: 'connected', currentPath: '/browsed' }, selectedFiles: new Set(['selected']), files: [selected] },
side: 'left', isPaneFocused: true,
@@ -39,9 +40,9 @@ test('refresh keeps scroll, while explicit sorting and filters reveal the select
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(render(), { createNodeMock: (element) => element.props['data-section'] === 'terminal-sftp-list' ? container : null }); });
assert.equal(scrolls, 1);
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
props = { ...props, pane: { ...props.pane, files: [selected, added] }, sortedDisplayFiles: [added, selected] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 1, 'refresh must not pull the viewport back to the selection');
assert.equal(scrolls, 1, 'a reconnect listing update must not pull the viewport back to the selection');
props = { ...props, sorting: { ...props.sorting, sortOrder: 'desc' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 2, 'sorting retains the prior reveal-selection behavior');
+2 -2
View File
@@ -208,8 +208,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
context: object;
} | null>(null);
useEffect(() => {
// Refreshes preserve selection identity; explicit sorting/filter/layout
// changes still reveal the selected row using the usual behavior.
// A listing update can retain the selection during reconnect. Only
// selection or view changes should reveal the selected row again.
const last = lastScrolledSelectionRef.current;
if (last?.selection === pane.selectedFiles && last.context === selectionScrollContext) return;
lastScrolledSelectionRef.current = { selection: pane.selectedFiles, context: selectionScrollContext };
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import type { Identity } from '../../types';
import type { Host } from '../../types';
import type { PendingAuth } from './runtime/createTerminalSessionStarters';
test('saved password identity retries with its username and supports both submit paths', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
});
const { window } = dom;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of [
'window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement',
'HTMLTextAreaElement', 'Element', 'SVGElement', 'Node', 'NodeFilter',
'MutationObserver', 'CustomEvent', 'DOMRect', 'Event', 'KeyboardEvent',
'MouseEvent', 'PointerEvent',
]) {
installGlobal(key, key === 'window' ? window : window[key as keyof typeof window]);
}
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
installGlobal('ResizeObserver', ResizeObserverStub);
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
const { TerminalAuthDialog } = await import('./TerminalAuthDialog.tsx');
const { useTerminalAuthState } = await import('./hooks/useTerminalAuthState.ts');
const identities: Identity[] = [
{ id: 'saved', label: 'Password B', username: 'deploy', authMethod: 'password', password: 'saved-secret', created: 0 },
{ id: 'unusable', label: 'Unreadable', username: 'root', authMethod: 'password', password: 'enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==', created: 1 },
];
const host: Host = {
id: 'host', label: 'Host', hostname: 'localhost', port: 22, username: 'root',
identityId: 'old', authMethod: 'password', password: 'wrong', tags: [], os: 'linux',
};
const submissions: Array<{ username?: string; password?: string; save?: boolean }> = [];
const savedHosts: Host[] = [];
const logs: string[][] = [];
const Form = () => {
const pendingAuthRef = React.useRef<PendingAuth>(null);
const termRef = React.useRef({ clear() {} } as never);
const auth = useTerminalAuthState({
host, identities, pendingAuthRef, termRef,
onUpdateHost: (updated) => savedHosts.push(updated),
onStartSession: () => submissions.push({
username: pendingAuthRef.current?.username,
password: pendingAuthRef.current?.password,
save: pendingAuthRef.current?.savedToHost,
}),
setStatus: () => {}, setProgressLogs: (next) => {
logs.push(typeof next === 'function' ? next([]) : next);
},
});
return <TerminalAuthDialog
authMethod={auth.authMethod} setAuthMethod={auth.setAuthMethod}
authUsername={auth.authUsername} setAuthUsername={auth.setAuthUsername}
authPassword={auth.authPassword} setAuthPassword={auth.setAuthPassword}
selectedIdentityId={auth.selectedIdentityId} onSelectIdentity={auth.selectIdentity}
authKeyId={auth.authKeyId} setAuthKeyId={auth.setAuthKeyId}
authPassphrase={auth.authPassphrase} setAuthPassphrase={auth.setAuthPassphrase}
showAuthPassphrase={auth.showAuthPassphrase} setShowAuthPassphrase={auth.setShowAuthPassphrase}
showAuthPassword={auth.showAuthPassword} setShowAuthPassword={auth.setShowAuthPassword}
authRetryMessage="Authentication failed" keys={[]} identities={identities}
onSubmit={() => auth.submit({ saveToHost: true })}
onSubmitWithoutSave={() => auth.submit({ saveToHost: false })}
onCancel={() => {}} isValid={auth.isValid}
/>;
};
const root = createRoot(window.document.getElementById('root')!);
const button = (label: string) => (Array.from(window.document.querySelectorAll('button')) as HTMLButtonElement[])
.find(item => item.textContent?.includes(label));
try {
await act(async () => root.render(<I18nProvider locale="en"><Form /></I18nProvider>));
assert.ok(button('Use saved identity'));
await act(async () => button('Use saved identity')!.click());
assert.ok(button('Password B'));
assert.equal(button('Unreadable'), undefined);
await act(async () => button('Password B')!.click());
assert.equal((window.document.getElementById('auth-username') as HTMLInputElement).value, 'deploy');
assert.equal((window.document.getElementById('auth-password') as HTMLInputElement).value, '');
assert.equal(window.document.body.textContent?.includes('saved-secret'), false);
assert.equal(window.document.querySelector<HTMLInputElement>('#auth-password')?.outerHTML.includes('saved-secret'), false);
assert.equal((window.document.querySelector('#auth-password + button') as HTMLButtonElement).disabled, true);
await act(async () => button('Continue')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: false });
assert.equal(savedHosts.length, 0);
await act(async () => window.document.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click());
await act(async () => button('Continue & Save')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: true });
assert.equal(savedHosts.at(-1)?.identityId, 'saved');
assert.equal(savedHosts.at(-1)?.password, undefined);
assert.equal(savedHosts.at(-1)?.savePassword, true);
assert.equal(logs.flat().some((line) => line.includes('saved-secret')), false);
const passwordInput = window.document.getElementById('auth-password') as HTMLInputElement;
const setInputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
assert.ok(setInputValue);
await act(async () => {
setInputValue.call(passwordInput, 'manual-secret');
passwordInput.dispatchEvent(new window.Event('input', { bubbles: true }));
});
assert.ok(button('Use saved identity'), 'manual edit must clear the saved identity label');
await act(async () => button('Continue')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'manual-secret', save: false });
} finally {
await act(async () => root.unmount());
window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+91 -4
View File
@@ -2,11 +2,12 @@
* Terminal Authentication Dialog
* Displays auth form with password/key selection for SSH connection
*/
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug, UserRound } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { SSHKey } from '../../types';
import { Identity, SSHKey } from '../../types';
import { listPasswordAuthIdentities } from '../../domain/authIdentityPicker';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -32,6 +33,10 @@ export interface TerminalAuthDialogProps {
setShowAuthPassword: (show: boolean) => void;
authRetryMessage: string | null;
keys: SSHKey[];
/** Keychain password identities offered for reuse on re-authentication (#3475). */
identities?: Identity[];
selectedIdentityId?: string | null;
onSelectIdentity?: (identityId: string) => void;
onSubmit: () => void;
onSubmitWithoutSave?: () => void;
onCancel: () => void;
@@ -55,6 +60,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
setShowAuthPassword,
authRetryMessage,
keys,
identities,
selectedIdentityId,
onSelectIdentity,
onSubmit,
onSubmitWithoutSave,
onCancel,
@@ -76,9 +84,25 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
const [identityDropdownOpen, setIdentityDropdownOpen] = React.useState(false);
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
// Password identities reusable on re-authentication (#3475). Only rendered
// when at least one usable saved password exists; manual entry stays.
const passwordIdentities = React.useMemo(
() => listPasswordAuthIdentities(identities),
[identities],
);
const selectedIdentity = selectedIdentityId
? passwordIdentities.find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
: null;
const handleSelectIdentity = (identity: Identity) => {
onSelectIdentity?.(identity.id);
setIdentityDropdownOpen(false);
};
return (
<>
{/* Auth method tabs */}
@@ -139,9 +163,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
<Input
id="auth-password"
type={showAuthPassword ? 'text' : 'password'}
value={authPassword}
value={selectedIdentity ? '' : authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
placeholder={selectedIdentity ? t("terminal.auth.selectIdentity") : t("terminal.auth.password.placeholder")}
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
autoFocus={!!authRetryMessage}
onKeyDown={handleKeyDown}
@@ -150,10 +174,73 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowAuthPassword(!showAuthPassword)}
disabled={Boolean(selectedIdentity)}
>
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{onSelectIdentity && passwordIdentities.length > 0 && (
<Popover open={identityDropdownOpen} onOpenChange={setIdentityDropdownOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-colors text-left",
selectedIdentity
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50"
)}
>
<div className="h-6 w-6 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
<UserRound size={12} />
</div>
<span
className={cn(
"flex-1 min-w-0 truncate text-sm",
selectedIdentity ? "font-medium" : "text-muted-foreground"
)}
>
{selectedIdentity
? selectedIdentity.label
: t("terminal.auth.selectIdentity")}
</span>
{selectedIdentity?.username && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{selectedIdentity.username}
</span>
)}
<ChevronDown size={14} className="text-muted-foreground shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<div className="max-h-60 overflow-y-auto">
{passwordIdentities.map((identity) => (
<button
key={identity.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
selectedIdentity?.id === identity.id
? "bg-primary/10 text-primary"
: "hover:bg-secondary/80"
)}
onClick={() => handleSelectIdentity(identity)}
>
<div className="h-7 w-7 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
<UserRound size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{identity.label}</div>
{identity.username && (
<div className="text-xs text-muted-foreground truncate">{identity.username}</div>
)}
</div>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{'\u2022'.repeat(8)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
) : (
<>
@@ -453,6 +453,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
return (
<div
className="flex-shrink-0 flex flex-col"
data-section="terminal-compose-bar"
style={{
height: barHeight,
backgroundColor: theme.resolvedBg,
@@ -259,3 +259,57 @@ test("disconnected observer surfaces do not advertise a reconnect they cannot pe
assert.equal(markup.includes("Start over"), false);
assert.match(markup, /Close session/);
});
test("re-auth dialog offers saved password identities from the vault (#3475)", () => {
const baseAuthProps = {
authMethod: "password" as const,
setAuthMethod: () => {},
authUsername: "root",
setAuthUsername: () => {},
authPassword: "",
setAuthPassword: () => {},
selectedIdentityId: null,
onSelectIdentity: () => {},
authKeyId: null,
setAuthKeyId: () => {},
authPassphrase: "",
setAuthPassphrase: () => {},
showAuthPassphrase: false,
setShowAuthPassphrase: () => {},
showAuthPassword: false,
setShowAuthPassword: () => {},
authRetryMessage: "Authentication failed. Please try again.",
onSubmit: () => {},
onCancel: () => {},
isValid: true,
};
const withIdentities = renderDialog({
needsAuth: true,
authProps: {
...baseAuthProps,
identities: [
{
id: "identity-1",
label: "Password B",
username: "deploy",
authMethod: "password",
password: "secret",
created: 0,
},
],
},
});
assert.match(withIdentities, /Use saved identity/);
assert.match(withIdentities, /Authentication failed\. Please try again\./);
// Stored passwords are never rendered, only masked.
assert.equal(withIdentities.includes("secret"), false);
const withoutIdentities = renderDialog({
needsAuth: true,
authProps: { ...baseAuthProps },
});
assert.equal(withoutIdentities.includes("Use saved identity"), false);
});
+4 -1
View File
@@ -391,7 +391,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -1262,6 +1262,9 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
identities,
selectedIdentityId: auth.selectedIdentityId,
onSelectIdentity: auth.selectIdentity,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
@@ -1,7 +1,8 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { RefObject } from "react";
import type { Host, TerminalSession } from "../../../types";
import type { Host, Identity, TerminalSession } from "../../../types";
import { listPasswordAuthIdentities } from "../../../domain/authIdentityPicker";
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
import { logger } from "../../../lib/logger";
@@ -21,27 +22,27 @@ export const buildSavedAuthHostUpdate = (
username: string;
password: string;
keyId: string | null;
identityId?: string | null;
},
): Host => ({
...host,
username: auth.username,
authMethod: auth.authMethod,
password: auth.authMethod === "password" ? auth.password : undefined,
password: auth.identityId ? undefined : auth.authMethod === "password" ? auth.password : undefined,
savePassword: auth.authMethod === "password" ? true : host.savePassword,
identityFileId:
auth.authMethod === "key" || auth.authMethod === "certificate"
? (auth.keyId ?? undefined)
: undefined,
// Detach stale Keychain identity on explicit credential save (#1956):
// resolveHostAuth prefers identity credentials over host fields.
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
// host-level override and does not re-inherit a group-level identity; consumers
// check host.identityId truthiness so "" behaves as "no identity".
identityId: "",
// An explicitly chosen saved identity remains linked to the host. Manual
// credentials detach the stale identity; empty string prevents inheriting
// an unrelated group identity on the next connection.
identityId: auth.identityId || "",
});
export const useTerminalAuthState = ({
host,
identities,
pendingAuthRef,
termRef,
onUpdateHost,
@@ -50,6 +51,7 @@ export const useTerminalAuthState = ({
setProgressLogs,
}: {
host: Host;
identities: Identity[];
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
@@ -62,6 +64,7 @@ export const useTerminalAuthState = ({
const [authUsername, setAuthUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
const [authPassword, setAuthPassword] = useState("");
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
const [authPassphrase, setAuthPassphrase] = useState("");
const [showAuthPassword, setShowAuthPassword] = useState(false);
@@ -73,6 +76,7 @@ export const useTerminalAuthState = ({
setAuthRetryMessage(null);
setAuthUsername(host.username || "root");
setAuthPassword("");
setSelectedIdentityId(null);
setAuthKeyId(null);
setAuthPassphrase("");
setShowAuthPassword(false);
@@ -80,12 +84,38 @@ export const useTerminalAuthState = ({
setSaveCredentials(true);
}, [host.id, host.username]);
const selectedIdentity = useMemo(() =>
selectedIdentityId
? listPasswordAuthIdentities(identities).find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
: undefined,
[authUsername, identities, selectedIdentityId]);
const isValid = useMemo(() => {
if (!authUsername.trim()) return false;
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
if (authMethod === "password") return Boolean(selectedIdentity) || isAuthPasswordProvided(authPassword);
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
return false;
}, [authKeyId, authMethod, authPassword, authUsername]);
}, [authKeyId, authMethod, authPassword, authUsername, selectedIdentity]);
const updateAuthPassword = useCallback((password: string) => {
setSelectedIdentityId(null);
setAuthPassword(password);
}, []);
const updateAuthUsername = useCallback((username: string) => {
setSelectedIdentityId(null);
setAuthUsername(username);
}, []);
const selectIdentity = useCallback((identityId: string) => {
const identity = listPasswordAuthIdentities(identities).find((item) => item.id === identityId);
if (!identity) return;
setAuthMethod("password");
setAuthUsername(identity.username);
setAuthPassword("");
setShowAuthPassword(false);
setSelectedIdentityId(identity.id);
}, [identities]);
const resetForRetry = useCallback(() => {
setNeedsAuth(false);
@@ -98,10 +128,11 @@ export const useTerminalAuthState = ({
if (!isValid) return;
const shouldSave = opts?.saveToHost ?? saveCredentials;
const password = selectedIdentity?.password ?? authPassword;
pendingAuthRef.current = {
authMethod,
username: authUsername,
password: authMethod === "password" ? authPassword : undefined,
password: authMethod === "password" ? password : undefined,
keyId:
authMethod === "key" || authMethod === "certificate"
? (authKeyId ?? undefined)
@@ -118,8 +149,9 @@ export const useTerminalAuthState = ({
buildSavedAuthHostUpdate(host, {
authMethod,
username: authUsername,
password: authPassword,
password,
keyId: authKeyId,
identityId: authMethod === "password" ? selectedIdentity?.id : undefined,
}),
);
}
@@ -152,6 +184,7 @@ export const useTerminalAuthState = ({
onUpdateHost,
pendingAuthRef,
saveCredentials,
selectedIdentity,
setProgressLogs,
setStatus,
termRef,
@@ -164,11 +197,13 @@ export const useTerminalAuthState = ({
authRetryMessage,
setAuthRetryMessage,
authUsername,
setAuthUsername,
setAuthUsername: updateAuthUsername,
authMethod,
setAuthMethod,
authPassword,
setAuthPassword,
setAuthPassword: updateAuthPassword,
selectedIdentityId,
selectIdentity,
authKeyId,
setAuthKeyId,
authPassphrase,
@@ -2869,6 +2869,14 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured?.password, "temporary-secret");
assert.equal(captured?.sftpReuseOptions, undefined);
// A different login user must never alias the original profile to SFTP.
Object.assign(ctx.host, { password: undefined });
ctx.pendingAuthRef.current.username = "deploy";
captured = undefined;
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured?.username, "deploy");
assert.equal(captured?.sftpReuseOptions, undefined);
});
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
@@ -675,7 +675,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
requiresMfa: !!ctx.host.requiresMfa,
port: ctx.host.port || 22,
password: attempt.password,
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
@@ -11,6 +11,7 @@ import {
resolveTerminalDropErrorMessage,
} from "./hooks/useTerminalDragDrop";
import { resolvePreferredTerminalCwd } from "./sftpCwd";
import { resolvePasswordAuthSftpHost } from "../../domain/authIdentityPicker";
const host = {
id: "host-1",
@@ -610,6 +611,36 @@ test("remote SSH folder drop to /root uses a resolved identity password", async
assert.equal(openedHost?.identityId, "id-1");
});
test("folder drop after choosing deploy identity keeps its saved sudo password", async () => {
const rootHost = { ...host, username: "root", identityId: "root-id" };
const identity = {
id: "deploy-id", label: "Deploy", username: "deploy", authMethod: "password" as const,
password: "deploy-secret", created: 0,
};
const sftpHost = resolvePasswordAuthSftpHost(rootHost, [identity], {
authMethod: "password", username: "deploy", password: "deploy-secret", savedToHost: false,
});
let openedHost: Host | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: sftpHost,
resolvedLoginUsername: sftpHost.username,
resolvedSudoPassword: identity.password,
isLocalConnection: false,
onOpenSftp: (nextHost) => { openedHost = nextHost; },
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedHost?.username, "deploy");
assert.equal(openedHost?.identityId, "deploy-id");
assert.equal(openedHost?.sftpSudo, true);
assert.equal(rootHost.identityId, "root-id");
});
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
let openedSftp = false;
+1 -1
View File
@@ -176,7 +176,7 @@ export interface TerminalProps {
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (fontSize: number) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
@@ -1,4 +1,3 @@
import { publishTerminalCommandCompletion } from "../../application/state/terminalCommandCompletion";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
@@ -190,7 +189,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishTerminalCommandCompletion(sessionId);
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelLeft, PanelRight, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelBottom, PanelLeft, PanelRight, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
import {
buildSidePanelChromeThemeFromTerminalTheme,
buildTerminalSidePanelCssVars,
@@ -22,10 +22,15 @@ import {
useTerminalSidePanelTabOrder,
} from '../../application/state/terminalSidePanelTabs';
import {
clampTerminalSidePanelHeight,
clampTerminalSidePanelWidth,
getTerminalSidePanelAvailableHeight,
getTerminalSidePanelAvailableWidth,
getTerminalSidePanelMaxShownTools,
getTerminalSidePanelMaxHeight,
getTerminalSidePanelMaxWidth,
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT,
} from '../../application/state/terminalSidePanelWidth';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
@@ -41,10 +46,12 @@ import {
MAX_SIDE_PANEL_PANES,
canSplitSidePanelPaneAtSize,
collectSidePanelPanes,
cycleSidePanelDockPosition,
getFocusedSidePanelPane,
getSidePanelNodeMinimumPixels,
getSidePanelSplitResizeBounds,
SIDE_PANEL_SPLIT_DIVIDER_PIXELS,
type SidePanelDockPosition,
type SidePanelLayout,
type SidePanelLayoutNode,
type SidePanelSplitDirection,
@@ -593,8 +600,11 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
setSidePanelPosition,
setSidePanelWidth,
persistSidePanelWidth,
setSidePanelHeight,
persistSidePanelHeight,
sidePanelPosition,
sidePanelWidth,
sidePanelHeight,
t,
terminalTheme,
hotkeyScheme,
@@ -617,10 +627,18 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
: ctxResolvedPreviewTheme);
const [resizePreviewWidth, setResizePreviewWidth] = useState<number | null>(null);
const [resizePreviewHeight, setResizePreviewHeight] = useState<number | null>(null);
const isBottomDock = sidePanelPosition === 'bottom';
const shellRef = useRef<HTMLDivElement>(null);
const shellResizeCleanupRef = useRef<(() => void) | null>(null);
const updateAvailableSurfaceRef = useRef<() => void>(() => {});
const [availableSurfaceWidth, setAvailableSurfaceWidth] = useState(0);
const availableSurfaceWidthRef = useRef(availableSurfaceWidth);
// Full terminal-layer width; unlike `availableSurfaceWidth` this does not
// subtract the workspace focus sidebar (relevant for the bottom dock).
const [terminalLayerWidth, setTerminalLayerWidth] = useState(0);
const [availableSurfaceHeight, setAvailableSurfaceHeight] = useState(0);
const availableSurfaceHeightRef = useRef(availableSurfaceHeight);
const [paneHosts, setPaneHosts] = useState<Map<SidePanelTab, HTMLElement>>(new Map());
const [parkingHost, setParkingHost] = useState<HTMLElement | null>(null);
const [overlayRoot, setOverlayRoot] = useState<HTMLElement | null>(null);
@@ -640,9 +658,14 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
setOverlayRoot(terminalLayer);
let observedFocusSidebar: Element | null = null;
let observedComposeBar: Element | null = null;
let observedWorkspaceColumn: Element | null = null;
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => updateAvailableWidth());
const mutationObserver = typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => updateAvailableWidth());
const updateAvailableWidth = () => {
const focusSidebar = terminalLayer.querySelector('[data-section="terminal-workspace-sidebar"]');
if (resizeObserver && focusSidebar !== observedFocusSidebar) {
@@ -650,27 +673,77 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
observedFocusSidebar = focusSidebar;
if (focusSidebar) resizeObserver.observe(focusSidebar);
}
// The workspace compose bar is a flex-shrink-0 sibling of the terminal
// area inside the workspace column; re-discover it on mount/unmount and
// measure it on its own resize so the dock never starves the terminal.
const composeBar = terminalLayer.querySelector('[data-section="terminal-compose-bar"]');
if (resizeObserver && composeBar !== observedComposeBar) {
if (observedComposeBar) resizeObserver.unobserve(observedComposeBar);
observedComposeBar = composeBar;
if (composeBar) resizeObserver.observe(composeBar);
}
const workspaceColumn = terminalLayer.querySelector(
'[data-section="terminal-workspace-column"]',
);
// MutationObserver has no unobserve; observe() on a node replaces its
// previous registration, so only newly seen columns need registering.
if (mutationObserver && workspaceColumn && workspaceColumn !== observedWorkspaceColumn) {
observedWorkspaceColumn = workspaceColumn;
mutationObserver.observe(workspaceColumn, { childList: true });
}
const layerWidth = terminalLayer.getBoundingClientRect().width;
const nextWidth = getTerminalSidePanelAvailableWidth(
terminalLayer.getBoundingClientRect().width,
layerWidth,
focusSidebar?.getBoundingClientRect().width ?? 0,
);
setTerminalLayerWidth((current) => current === layerWidth ? current : layerWidth);
availableSurfaceWidthRef.current = nextWidth;
setAvailableSurfaceWidth((current) => current === nextWidth ? current : nextWidth);
// Bottom dock shares the full terminal layer height; only the host-tree
// sidebar overlaps horizontally. The compose bar is a flex-shrink-0
// sibling below the terminal area, though, so its height is reserved
// too — otherwise the MIN_TERMINAL_HEIGHT floor applies to the whole
// workspace row and a tall compose bar can collapse the terminal.
const nextHeight = getTerminalSidePanelAvailableHeight(
terminalLayer.getBoundingClientRect().height,
composeBar?.getBoundingClientRect().height ?? 0,
);
availableSurfaceHeightRef.current = nextHeight;
setAvailableSurfaceHeight((current) => current === nextHeight ? current : nextHeight);
};
updateAvailableSurfaceRef.current = updateAvailableWidth;
updateAvailableWidth();
resizeObserver?.observe(terminalLayer);
const mutationObserver = typeof MutationObserver === 'undefined'
? null
: new MutationObserver(updateAvailableWidth);
mutationObserver?.observe(terminalLayer, { childList: true });
// The focus sidebar is nested inside the workspace row wrapper, so a
// focus-mode toggle mutates the wrapper rather than the terminal layer
// (whose own dimensions stay unchanged). Watch the wrapper as well so the
// nested sidebar is discovered and observed when it mounts/unmounts.
const workspaceRow = terminalLayer.querySelector(
'[data-section="terminal-workspace-row"]',
);
if (workspaceRow) mutationObserver?.observe(workspaceRow, { childList: true });
updateAvailableWidth();
return () => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
observedFocusSidebar = null;
observedComposeBar = null;
observedWorkspaceColumn = null;
updateAvailableSurfaceRef.current = () => {};
setOverlayRoot(null);
};
}, []);
// The compose bar mounts inside the terminal pane, below the observed
// workspace column. Remeasure when it opens/closes or the active tab changes;
// the ResizeObserver above then follows subsequent compose-bar height drags.
useLayoutEffect(() => {
updateAvailableSurfaceRef.current();
// The compose bar is rendered by a sibling subtree. Its mount can land
// after this layout effect, so measure again once that commit has painted.
const frame = requestAnimationFrame(() => updateAvailableSurfaceRef.current());
return () => cancelAnimationFrame(frame);
}, [activeTabId, ctx.isComposeBarOpen]);
const handlePaneHostChange = useCallback((tool: SidePanelTab, host: HTMLElement | null) => {
setPaneHosts((current) => {
if (host && current.get(tool) === host) return current;
@@ -735,9 +808,22 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
resizePreviewWidth,
sidePanelWidth,
});
// Bottom dock reuses the same open/hidden gating with the height axis.
const requestedShellHeight = getTerminalSidePanelShellWidth({
activeSidePanelTab,
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL && activePaneCount <= 1,
isSidePanelOpenForCurrentTab,
resizePreviewWidth: resizePreviewHeight,
sidePanelWidth: sidePanelHeight,
});
const sidePanelContentMinimumWidth = activeSidePanelLayout
? getSidePanelNodeMinimumPixels(activeSidePanelLayout.root, 'vertical')
: 0;
const sidePanelContentMinimumHeight = activeSidePanelLayout
? getSidePanelNodeMinimumPixels(activeSidePanelLayout.root, 'horizontal')
// The shared toolbar sits above the pane tree and consumes shell height.
+ TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT
: 0;
const shellWidth = requestedShellWidth > 0
? clampTerminalSidePanelWidth(
requestedShellWidth,
@@ -745,12 +831,67 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
sidePanelContentMinimumWidth,
)
: 0;
const shellHeight = requestedShellHeight > 0
? clampTerminalSidePanelHeight(
requestedShellHeight,
availableSurfaceHeight,
sidePanelContentMinimumHeight,
)
: 0;
const shellSize = isBottomDock ? shellHeight : shellWidth;
const handleSidePanelResizeStart = useCallback((event: React.MouseEvent) => {
if (!isSidePanelOpenForCurrentTab) return;
event.preventDefault();
shellResizeCleanupRef.current?.();
terminalLayoutSuppressStore.begin();
if (sidePanelPosition === 'bottom') {
const startY = event.clientY;
const startHeight = shellRef.current?.getBoundingClientRect().height ?? shellHeight;
let lastHeight = startHeight;
let rafId: number | null = null;
// Dragging the top edge upward grows the bottom-docked panel.
const onMouseMove = (moveEvent: MouseEvent) => {
lastHeight = clampTerminalSidePanelHeight(
startHeight + (startY - moveEvent.clientY),
availableSurfaceHeightRef.current,
sidePanelContentMinimumHeight,
);
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
setResizePreviewHeight(lastHeight);
});
};
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
terminalLayoutSuppressStore.end();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', finish);
window.removeEventListener('blur', finish);
if (shellResizeCleanupRef.current === cleanup) shellResizeCleanupRef.current = null;
};
const finish = () => {
// A narrow viewport may temporarily force the shell below its saved
// height. A click or blur must not overwrite that preference.
if (lastHeight !== startHeight && lastHeight >= TERMINAL_SIDE_PANEL_MIN_HEIGHT) {
setSidePanelHeight(lastHeight);
persistSidePanelHeight(lastHeight);
}
setResizePreviewHeight(null);
cleanup();
};
shellResizeCleanupRef.current = cleanup;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', finish);
window.addEventListener('blur', finish);
return;
}
const startX = event.clientX;
const startWidth = shellRef.current?.getBoundingClientRect().width ?? shellWidth;
let lastWidth = startWidth;
@@ -793,10 +934,14 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
window.addEventListener('blur', finish);
}, [
isSidePanelOpenForCurrentTab,
persistSidePanelHeight,
persistSidePanelWidth,
setSidePanelHeight,
setSidePanelWidth,
sidePanelContentMinimumHeight,
sidePanelContentMinimumWidth,
sidePanelPosition,
shellHeight,
shellWidth,
]);
@@ -967,18 +1112,21 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
}
return parts;
}, [activeSidePanelTab, partitionSidePanelTabs]);
// Bottom dock spans the full layer width (below the workspace focus
// sidebar row), so measure tab overflow against the terminal layer itself.
const sidePanelTabFitWidth = isBottomDock ? terminalLayerWidth : shellWidth;
const { shown: shownSidePanelTabs, collapsed: collapsedSidePanelTabs } = useMemo(
() => fitTerminalSidePanelTabs({
shown: configuredShownSidePanelTabs,
collapsed: configuredCollapsedSidePanelTabs,
active: activeSidePanelTab,
maxShown: getTerminalSidePanelMaxShownTools(shellWidth),
maxShown: getTerminalSidePanelMaxShownTools(sidePanelTabFitWidth),
}),
[
activeSidePanelTab,
configuredCollapsedSidePanelTabs,
configuredShownSidePanelTabs,
shellWidth,
sidePanelTabFitWidth,
],
);
@@ -1001,14 +1149,26 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
ref={shellRef}
inert={activeMagnifiedPane ? true : undefined}
style={{
width: shellWidth,
maxWidth: getTerminalSidePanelMaxWidth(availableSurfaceWidth),
...(isBottomDock
? {
height: shellSize,
maxHeight: getTerminalSidePanelMaxHeight(availableSurfaceHeight),
}
: {
width: shellSize,
maxWidth: getTerminalSidePanelMaxWidth(availableSurfaceWidth),
}),
contain: 'layout paint style',
}}
className={cn(
'flex-shrink-0 h-full relative z-20',
shellWidth === 0 && 'overflow-hidden',
sidePanelPosition === 'right' && 'order-last',
'flex-shrink-0 relative z-20',
isBottomDock ? 'w-full' : 'h-full',
shellSize === 0 && 'overflow-hidden',
// Bottom dock: the section is the first child of the outer flex-col
// (stable tree position across dock changes); order-last keeps it
// visually below the workspace row. Side dock: order-last moves it to
// the right edge when docked right.
(sidePanelPosition === 'right' || isBottomDock) && 'order-last',
)}
data-section="terminal-side-panel-shell"
data-side-panel-position={sidePanelPosition}
@@ -1016,8 +1176,13 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
{isSidePanelOpenForCurrentTab && !isAiShellForceHidden && (
<div
className={cn(
'absolute top-0 h-full w-2 cursor-ew-resize z-30',
sidePanelPosition === 'left' ? 'right-[-3px]' : 'left-[-3px]',
'absolute z-30',
isBottomDock
? 'left-0 w-full h-2 cursor-ns-resize top-[-3px]'
: cn(
'top-0 h-full w-2 cursor-ew-resize',
sidePanelPosition === 'left' ? 'right-[-3px]' : 'left-[-3px]',
),
)}
data-section="terminal-side-panel-resizer"
onMouseDown={handleSidePanelResizeStart}
@@ -1044,6 +1209,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
...(isSidePanelOpenForCurrentTab && sidePanelPosition === 'right'
? { borderLeft: `1px solid ${sidePanelTheme.separator}` }
: {}),
...(isSidePanelOpenForCurrentTab && isBottomDock
? { borderTop: `1px solid ${sidePanelTheme.separator}` }
: {}),
}}
>
{isSidePanelOpenForCurrentTab && !isAiShellForceHidden && (
@@ -1232,13 +1400,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{ color: sidePanelTheme.mutedFg }}
onClick={() => setSidePanelPosition((p: 'left' | 'right') => (p === 'left' ? 'right' : 'left'))}
onClick={() => setSidePanelPosition((p: SidePanelDockPosition) => cycleSidePanelDockPosition(p))}
>
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
{sidePanelPosition === 'left' && <PanelRight size={15} />}
{sidePanelPosition === 'right' && <PanelBottom size={15} />}
{sidePanelPosition === 'bottom' && <PanelLeft size={15} />}
</Btn>
</TooltipTrigger>
<TooltipContent side="bottom">
{sidePanelPosition === 'left' ? t('terminal.layer.movePanelRight') : t('terminal.layer.movePanelLeft')}
{sidePanelPosition === 'left'
? t('terminal.layer.movePanelRight')
: sidePanelPosition === 'right'
? t('terminal.layer.movePanelBottom')
: t('terminal.layer.movePanelLeft')}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -1,4 +1,3 @@
import { getTopTabsChromeRoots } from '../../application/app/topTabsChromeTheme';
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { activeTabStore } from '../../application/state/activeTabStore';
@@ -228,22 +227,22 @@ export const clearHostTreePreviewVars = () => {
export const clearTopTabsPreviewVars = () => {
if (typeof document === 'undefined') return;
for (const tabsRoot of getTopTabsChromeRoots()) {
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
removeStylePropertyIfSet(tabsRoot, '--background');
removeStylePropertyIfSet(tabsRoot, '--foreground');
removeStylePropertyIfSet(tabsRoot, '--accent');
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
removeStylePropertyIfSet(tabsRoot, '--primary');
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
removeStylePropertyIfSet(tabsRoot, '--secondary');
removeStylePropertyIfSet(tabsRoot, '--border');
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
removeStylePropertyIfSet(tabsRoot, '--background');
removeStylePropertyIfSet(tabsRoot, '--foreground');
removeStylePropertyIfSet(tabsRoot, '--accent');
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
removeStylePropertyIfSet(tabsRoot, '--primary');
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
removeStylePropertyIfSet(tabsRoot, '--secondary');
removeStylePropertyIfSet(tabsRoot, '--border');
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
};
export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
@@ -805,7 +804,7 @@ interface TerminalPaneProps {
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
@@ -1707,7 +1706,7 @@ interface TerminalPanesHostProps {
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
@@ -11,6 +11,7 @@ import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemMa
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, Workspace } from '../../types';
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
import { resolveTerminalSftpHost } from '../../domain/sftpTerminalIdentity';
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
import { TerminalLayerView } from './TerminalLayerView';
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
@@ -47,6 +48,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sessions = s.sessions as TerminalSession[];
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
const sftpHostSourceSessionForTab = s.sftpHostSourceSessionForTab as Map<string, string>;
const sftpAuthHostBySessionId = s.sftpAuthHostBySessionId as Map<string, Host>;
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
@@ -134,14 +137,19 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sftpActiveHost = useMemo((): Host | null => {
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [activeSession, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpHostForTab]);
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
const sessionHost = sessionId ? sessionHostsMap.get(sessionId) : null;
const stored = sftpHostForTab.get(activeTabId);
const authHost = sessionId && sftpHostSourceSessionForTab.has(activeTabId)
? sftpAuthHostBySessionId.get(sessionId)
: undefined;
return resolveTerminalSftpHost({
sessionHost,
storedHost: stored,
authenticatedHost: authHost,
followsTerminal: sftpHostSourceSessionForTab.has(activeTabId),
});
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpAuthHostBySessionId, sftpHostForTab, sftpHostSourceSessionForTab]);
// Keep the same-endpoint SSH session id across disconnected/connecting so
// SftpSidePanel can observe status transitions and rebind after Start over.
@@ -155,11 +163,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sessionHost = sessionHostsMap.get(session.id);
if (!sessionHost) return null;
const sameEndpoint =
sessionHost.hostname === sftpActiveHost.hostname
sessionHost.id === sftpActiveHost.id
&& sessionHost.hostname === sftpActiveHost.hostname
&& (sessionHost.port || 22) === (sftpActiveHost.port || 22)
&& (sessionHost.username || 'root') === (sftpActiveHost.username || 'root');
&& ((sessionHost.username || 'root') === (sftpActiveHost.username || 'root')
|| (activeTabId && sftpHostSourceSessionForTab.has(activeTabId)
&& sftpAuthHostBySessionId.get(session.id) === sftpActiveHost));
return sameEndpoint ? session.id : null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost, sftpAuthHostBySessionId, sftpHostSourceSessionForTab]);
const linkedTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab) return null;
@@ -375,6 +386,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
Set,
setDropHint,
setSftpHostForTab: s.setSftpHostForTab,
setSftpHostSourceSessionForTab: s.setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab: s.setSftpInitialLocationForTab,
setSftpPendingUploadsForTab: s.setSftpPendingUploadsForTab,
setAiMountedTabIds: s.setAiMountedTabIds,
@@ -388,6 +400,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
setWorkspaceArea,
sidePanelPosition: s.sidePanelPosition,
sidePanelWidth: s.sidePanelWidth,
sidePanelHeight: s.sidePanelHeight,
sftpActiveHost,
sftpHostForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
@@ -497,6 +510,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
persistSidePanelWidth: s.persistSidePanelWidth,
setSidePanelWidth: s.setSidePanelWidth,
persistSidePanelHeight: s.persistSidePanelHeight,
setSidePanelHeight: s.setSidePanelHeight,
handleSnippetClickForFocusedSession: s.handleSnippetClickForFocusedSession,
handleSnippetFromPanel: s.handleSnippetFromPanel,
handleRunScriptFromPanel: s.handleRunScriptFromPanel,
@@ -610,6 +625,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
sidePanelPosition: s.sidePanelPosition,
sidePanelWidth: s.sidePanelWidth,
sidePanelHeight: s.sidePanelHeight,
sidePanelOpenTabs,
sidePanelLayouts,
snippetPackages: s.snippetPackages,
@@ -671,6 +687,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleTerminalContextReaderChange,
hibernateHiddenTabs,
historySessionId,
s.isComposeBarOpen,
isFocusMode,
isSidePanelOpenForCurrentTab,
isTerminalLayerVisible,
@@ -681,6 +698,10 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sidePanelLayouts,
s.resolvedSessionHostIds,
sessions,
// Dock geometry lives in stableRef state; without these deps the memoized
// ctx keeps stale values after dock-cycle / bottom-height-drag updates.
s.sidePanelHeight,
s.sidePanelPosition,
s.terminalSettings,
showHostTreeSidebar,
sftpActiveHost,
+15 -3
View File
@@ -17,10 +17,12 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
ctx.hibernateHiddenTabs,
);
const isBottomDock = ctx.sidePanelPosition === 'bottom';
return (
<div
ref={ctx.workspaceOuterRef}
className="absolute inset-0 bg-background flex min-h-0"
className={`absolute inset-0 bg-background flex min-h-0${isBottomDock ? ' flex-col' : ''}`}
data-section="terminal-workspace"
inert={ctx.isTerminalLayerVisible ? undefined : true}
style={{
@@ -28,9 +30,19 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
left: hostTreeLayoutWidth,
}}
>
{/* Keep TerminalLayerSidePanelSection at a stable tree position (first
child) so cycling between side and bottom docks reorders it via flex
`order` instead of unmounting/recreating it. The wrapper below uses
`display: contents` in side-dock mode so its children participate in
the outer flex row exactly like direct children. */}
<TerminalLayerSidePanelSection ctx={ctx} />
<TerminalLayerFocusSidebarSection ctx={ctx} />
<TerminalLayerWorkspaceSection ctx={ctx} />
<div
data-section="terminal-workspace-row"
className={isBottomDock ? 'flex min-h-0 w-full flex-1' : 'contents'}
>
<TerminalLayerFocusSidebarSection ctx={ctx} />
<TerminalLayerWorkspaceSection ctx={ctx} />
</div>
</div>
);
}
@@ -146,7 +146,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
}, [activeMagnifiedTerminal]);
return (
<div className="flex-1 min-h-0 flex flex-col">
<div data-section="terminal-workspace-column" className="flex-1 min-h-0 flex flex-col">
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
{draggingSessionId && !isFocusMode && (
<div
@@ -154,6 +154,14 @@ test("terminal layer side panel stable ctx ignores linked terminal cwd changes",
);
});
test("terminal layer side panel remeasures when the compose bar opens", () => {
const closed = { isComposeBarOpen: false };
assert.equal(
terminalLayerSidePanelStableCtxEqual(closed, { isComposeBarOpen: true }),
false,
);
});
test("terminal layer side panel stable ctx re-renders when SFTP-relevant session fields change", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
@@ -239,7 +239,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'HistorySidePanel',
'History',
'sidePanelWidth',
'sidePanelHeight',
'sidePanelPosition',
'isComposeBarOpen',
'sidePanelOpenTabs',
'sidePanelLayouts',
'sftpHostForTab',
@@ -304,6 +306,8 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'handlePendingTerminalSelectionConsumed',
'setSidePanelWidth',
'persistSidePanelWidth',
'setSidePanelHeight',
'persistSidePanelHeight',
'handleToggleSftpFromBar',
'handleOpenScripts',
'handleOpenTheme',
@@ -13,6 +13,7 @@ import {
resolveSftpTransferNavigationTarget,
} from '../../domain/sftpTransferNavigation';
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
import { isSameSftpHostSelection } from '../../domain/sftpTerminalIdentity';
import { collectSessionIds } from '../../domain/workspace';
import {
moveSidePanelTabMap,
@@ -108,7 +109,7 @@ export function pruneTerminalTabMemoryState(
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { openPath } = useSftpBackend();
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
@@ -199,6 +200,13 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}
return next;
});
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
let next = prev;
for (const remap of remaps) {
next = moveSidePanelTabMap(next, remap);
}
return next;
});
setSftpInitialLocationForTab((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
@@ -263,6 +271,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
setNotesMountedTabIds,
setScriptsMountedTabIds,
setSftpHostForTab,
setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setSidePanelLayouts,
@@ -282,6 +291,17 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
resizePreviewWidth: null,
sidePanelWidth,
});
// Bottom dock trades width for height; the same open/hidden gate applies.
const sidePanelShellHeight = sidePanelPosition === 'bottom'
? getTerminalSidePanelShellWidth({
activeSidePanelTab,
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL
&& (!activeSidePanelLayout || collectSidePanelPanes(activeSidePanelLayout.root).length <= 1),
isSidePanelOpenForCurrentTab,
resizePreviewWidth: null,
sidePanelWidth: sidePanelHeight,
})
: 0;
const activityEscapeFiltersRef = useRef<any>(new Map());
@@ -291,6 +311,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
viewMode: undefined as string | undefined,
composeBarOpen: false,
shellWidth: 0,
shellHeight: 0,
width: 0,
height: 0,
});
@@ -394,6 +415,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostSourceSessionForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setAiMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
@@ -441,7 +463,8 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
&& height > 0
&& prev.width === width
&& prev.height === height
&& prev.shellWidth === sidePanelShellWidth;
&& prev.shellWidth === sidePanelShellWidth
&& prev.shellHeight === sidePanelShellHeight;
if (
dimensionsUnchanged
&& prev.workspaceId === activeWorkspaceId
@@ -455,6 +478,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
viewMode: activeWorkspaceViewMode,
composeBarOpen: isComposeBarOpen,
shellWidth: sidePanelShellWidth,
shellHeight: sidePanelShellHeight,
width,
height,
};
@@ -467,6 +491,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
shouldMeasureTerminalLayerLayout,
sidePanelPosition,
sidePanelShellWidth,
sidePanelShellHeight,
]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
@@ -475,10 +500,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
if (!activeTabId || !sftpActiveHost) return;
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
const stored = sftpHostForTab.get(activeTabId);
if (stored?.id === sftpActiveHost.id
&& stored?.hostname === sftpActiveHost.hostname
&& stored?.port === sftpActiveHost.port
&& stored?.protocol === sftpActiveHost.protocol) return;
if (isSameSftpHostSelection(stored, sftpActiveHost)) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(activeTabId, sftpActiveHost);
@@ -516,6 +538,11 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
// Bump initialLocation even when the host is already selected so the
// path-navigation effect re-runs after reopen.
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
const next = new Map(prev);
next.delete(tabId);
return next;
});
setSftpInitialLocationForTab((prev: Map<string, any>) => {
const next = new Map(prev);
next.delete(tabId);
@@ -642,7 +669,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
};
window.addEventListener('netcatty:open-sftp-transfer-target', handler);
return () => window.removeEventListener('netcatty:open-sftp-transfer-target', handler);
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
useEffect(() => {
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
+1 -1
View File
@@ -312,7 +312,7 @@ export const WindowControls: React.FC = memo(() => {
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
return (
<div className="ml-2 flex items-center h-7 overflow-visible app-no-drag">
<div className="ml-2 flex h-9 items-center app-no-drag">
<button type="button" className={controlClassName} onClick={handleMinimize}>
<Minus size={16} />
</button>
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
// Regression test for issue #3476: window control buttons must fill the full
// h-9 title bar so the hover highlight and hit area are vertically centered
// around the icon, instead of the buttons sitting in a bottom-anchored h-7 row.
test("window control buttons fill the h-9 title bar with centered hover highlight", () => {
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const buttonBlock = css.match(
/\[data-section="top-tabs"\] \.window-control-btn \{[\s\S]*?\n\}/,
)?.[0] ?? "";
assert.notEqual(buttonBlock, "", "window-control-btn rule must exist in index.css");
assert.match(buttonBlock, /height: 2\.25rem/, "buttons must span the full 2.25rem title bar height");
const beforeBlock = css.match(
/\[data-section="top-tabs"\] \.window-control-btn::before \{[\s\S]*?\n\}/,
)?.[0] ?? "";
assert.notEqual(beforeBlock, "");
assert.match(beforeBlock, /inset: 0/, "hover highlight must cover the whole button box");
assert.doesNotMatch(beforeBlock, /calc\(/, "no offset hack may remain on the highlight");
const tsx = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const containerMatch = tsx.match(/<div className="[^"]*app-no-drag">\s*\n\s*<button type="button" className=\{controlClassName\}/);
assert.notEqual(containerMatch, null, "WindowControls container must wrap the control buttons");
assert.match(containerMatch?.[0] ?? "", /h-9/, "WindowControls container must be h-9 to match the title bar");
const toolbar = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
assert.match(
toolbar,
/showWindowControls \? "relative z-20 h-9" : "h-7"/,
"the containing toolbar must fill the title bar and sit above its drag stripe",
);
});
+58
View File
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { listPasswordAuthIdentities, resolvePasswordAuthSftpHost } from "./authIdentityPicker.ts";
import type { Identity } from "./models.ts";
import type { Host } from "./models.ts";
const passwordIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: "id-1",
label: "Default password",
username: "root",
authMethod: "password",
password: "secret",
created: 0,
...overrides,
});
test("keeps only password identities with a usable stored password", () => {
const identities: Identity[] = [
passwordIdentity(),
passwordIdentity({ id: "id-2", label: "Password B", username: "deploy" }),
passwordIdentity({ id: "id-3", authMethod: "key", keyId: "key-1" }),
passwordIdentity({ id: "id-4", password: undefined }),
passwordIdentity({ id: "id-5", password: "" }),
passwordIdentity({ id: "id-6", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
passwordIdentity({ id: "id-7", username: "" }),
passwordIdentity({ id: "id-8", username: " " }),
];
const result = listPasswordAuthIdentities(identities);
assert.deepEqual(
result.map((identity) => identity.id),
["id-1", "id-2"],
);
});
test("tolerates missing identity lists", () => {
assert.deepEqual(listPasswordAuthIdentities(undefined), []);
assert.deepEqual(listPasswordAuthIdentities([]), []);
});
test("temporary deploy identity carries into SFTP without changing the saved host", () => {
const host = { id: "host-1", hostname: "example.test", username: "root", identityId: "root-id" } as Host;
const identities = [
passwordIdentity({ id: "root-id", password: "bad-root" }),
passwordIdentity({ id: "deploy-id", username: "deploy", password: "good-deploy" }),
];
const auth = { authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false };
assert.deepEqual(resolvePasswordAuthSftpHost(host, identities, auth), {
...host, username: "deploy", identityId: "deploy-id",
});
assert.equal(host.username, "root");
assert.equal(host.identityId, "root-id");
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, password: "manual" }), host);
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, savedToHost: true }), host);
assert.equal(resolvePasswordAuthSftpHost(host, identities, null), host);
});
+37
View File
@@ -0,0 +1,37 @@
import type { Host, Identity } from "./models";
import { sanitizeCredentialValue } from "./credentials";
/**
* Saved password identities that can be reused from the terminal
* re-authentication dialog. Only identities carrying a usable stored
* password and username qualify. Key identities are already selectable
* through the existing SSH key picker (#3475). Undecryptable vault ciphertext
* (`enc:v1:` placeholders left when hydration fails) is filtered out so
* those identities fall back to manual password re-entry.
*/
export const listPasswordAuthIdentities = (
identities: readonly Identity[] | undefined,
): Identity[] =>
(identities ?? []).filter(
(identity) =>
identity.authMethod === "password"
&& typeof identity.username === "string"
&& identity.username.trim().length > 0
&& typeof identity.password === "string"
&& (sanitizeCredentialValue(identity.password) ?? "").length > 0,
);
/** Use the temporary saved identity for SFTP opened from its authenticated terminal. */
export const resolvePasswordAuthSftpHost = (
host: Host,
identities: readonly Identity[] | undefined,
auth: { authMethod: string; username: string; password?: string; savedToHost?: boolean } | null,
): Host => {
if (!auth || auth.authMethod !== "password" || auth.savedToHost) return host;
const identity = listPasswordAuthIdentities(identities).find(
(candidate) => candidate.username === auth.username && candidate.password === auth.password,
);
return identity
? { ...host, username: identity.username, identityId: identity.id }
: host;
};
+85
View File
@@ -0,0 +1,85 @@
/**
* Tests for SSH key-type detection of imported keys.
* Fixtures are real ssh-keygen/openssl outputs committed inline so the tests
* run offline; each is a throwaway key with no passphrase.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { detectSshKeyType } from "./keyTypeDetect";
const ecdsaP256 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS\n1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQvnbE+AaCddvtJFMjaax10oenmFmQM\npXAcjy2UGyDgtfu3lEaPtaIH46w6N20f9yYAON2MvWW0/eRuCpdJHTkMAAAAsHVdVZ91XV\nWfAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC+dsT4BoJ12+0kU\nyNprHXSh6eYWZAylcByPLZQbIOC1+7eURo+1ogfjrDo3bR/3JgA43Yy9ZbT95G4Kl0kdOQ\nwAAAAgStX6Lob7zcAycW7KaJSCXLJrilDhJXG1WFQgGl9y62IAAAAUcnVubmVyQHJ1bm5l\ncnZtbHVuNXABAgME\n-----END OPENSSH PRIVATE KEY-----\n";
const ecdsaP384 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS\n1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSQS6EWgw29J9MLGrZLfMn76MmM5ALy\nQ4+04xJGATjEf6MEOIAK0wSBFZl8to7emVfBc1zzAj3ha+GBhJhb0um5SSm9FPyWwAiIJf\nBVzRLlmGzQovAI01eQmL1/4ucmSvUAAADgtNoSebTaEnkAAAATZWNkc2Etc2hhMi1uaXN0\ncDM4NAAAAAhuaXN0cDM4NAAAAGEEkEuhFoMNvSfTCxq2S3zJ++jJjOQC8kOPtOMSRgE4xH\n+jBDiACtMEgRWZfLaO3plXwXNc8wI94WvhgYSYW9LpuUkpvRT8lsAIiCXwVc0S5Zhs0KLw\nCNNXkJi9f+LnJkr1AAAAMQCYPLvcPHCq3bUIuaeu3p0bKq1/C2OHA1lC0hpq+1fDf6FTGR\nvFItzEY9ZtFvC3AWMAAAAUcnVubmVyQHJ1bm5lcnZtbHVuNXABAgM=\n-----END OPENSSH PRIVATE KEY-----\n";
const ecdsaP521 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS\n1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQBavWy9eXpHL30+xFqt3nDv+ePLDCD\nOg4N1cuMNIHckv5rgoDGvvDQMjtGczs3kC0xabLtv601MRUvBhFPwE4Y034A7vHCE6RAWp\nq00peOjvI+1LGT/O6VcK/jRek0Ff2SFyuRQnEr831OzbaSDmW2jGdy9nbqinb5YQJJxtap\nMZX1OywAAAEY25lDYduZQ2EAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ\nAAAIUEAWr1svXl6Ry99PsRard5w7/njywwgzoODdXLjDSB3JL+a4KAxr7w0DI7RnM7N5At\nMWmy7b+tNTEVLwYRT8BOGNN+AO7xwhOkQFqatNKXjo7yPtSxk/zulXCv40XpNBX9khcrkU\nJxK/N9Ts22kg5ltoxncvZ26op2+WECScbWqTGV9TssAAAAQgEPqPBDx3/8K4g0fzQn57FF\naCq4Cs2JZz9BHWx63zwlRQq8IhQKbZecPynvKNWLy8LOp8XfNVZTwQBKMz9QekGeuAAAAB\nRydW5uZXJAcnVubmVydm1sdW41cAECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ecdsaPkcs8 = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgugGgbsmFOKG8JT1q\n6Ay3bj1PDVhdp1XtdEHVE6NJzaahRANCAAToTR6xUeCdtmkog/Bo92A9ulj68geF\nKGBHlkOAK276jQJreRIbgzDvnuXJYIcdFT8Hld+NKcYpW3G9Ldk1dIaN\n-----END PRIVATE KEY-----\n";
const ed25519 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACByAuOzP1a94fFc6lzn1dkzlZulhkgFw7iwLTQZDxkBXgAAAJhgsFpyYLBa\ncgAAAAtzc2gtZWQyNTUxOQAAACByAuOzP1a94fFc6lzn1dkzlZulhkgFw7iwLTQZDxkBXg\nAAAEB+G4mdoS5EtP1EOdUWi2dtZOSbzlKgwHiIMGnu0F4kJnIC47M/Vr3h8VzqXOfV2TOV\nm6WGSAXDuLAtNBkPGQFeAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQ==\n-----END OPENSSH PRIVATE KEY-----\n";
const ed25519Pkcs8 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDRXAZBDSSce/bfa6hj2HEd62xJWSwTu9SEMTH94ytWaQAAAJg4DMdFOAzH\nRQAAAAtzc2gtZWQyNTUxOQAAACDRXAZBDSSce/bfa6hj2HEd62xJWSwTu9SEMTH94ytWaQ\nAAAEDCmdEVlGRm4wzFTThmFJUlHe2P38nLyTIK75rLl5uqC9FcBkENJJx79t9rqGPYcR3r\nbElZLBO71IQxMf3jK1ZpAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQ==\n-----END OPENSSH PRIVATE KEY-----\n";
const rsa = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAQEAksdZMjBmIgapaqsWuzUUxP3aiHf1YrPRrHlQVlojhSvhq9gKjUlW\n5kM2cy70jtPmw3hQHdY/aYXWDJ8VxOMiJ5HnacL0WzgRFxBh1UgbRUFPfu6z8I0xUvQxBT\nZdoIYqFL4UbfI8K4TrgqBDt1RQ7qipOsldeyM0sjVgspIpHa7m0nrlpKVcyGZOuQPaJxN6\npYlwEO/D9/0Y1Xy+x/RdWpFBDTR+4cJyBdF+u6QziLz4ckJPHVJ2i69WoWDFWk3YxKlfl0\nRXlSas/O07rrTvz8cflvVkcqt/tI3TuCic3eBpW5Y2z+49EKo764SiWmrQM4++75v0GC4T\nc0BJHKIUxQAAA9AbHmyUGx5slAAAAAdzc2gtcnNhAAABAQCSx1kyMGYiBqlqqxa7NRTE/d\nqId/Vis9GseVBWWiOFK+Gr2AqNSVbmQzZzLvSO0+bDeFAd1j9phdYMnxXE4yInkedpwvRb\nOBEXEGHVSBtFQU9+7rPwjTFS9DEFNl2ghioUvhRt8jwrhOuCoEO3VFDuqKk6yV17IzSyNW\nCykikdrubSeuWkpVzIZk65A9onE3qliXAQ78P3/RjVfL7H9F1akUENNH7hwnIF0X67pDOI\nvPhyQk8dUnaLr1ahYMVaTdjEqV+XRFeVJqz87TuutO/Pxx+W9WRyq3+0jdO4KJzd4Glblj\nbP7j0QqjvrhKJaatAzj77vm/QYLhNzQEkcohTFAAAAAwEAAQAAAQAJmgcrNyr55IQA/tfZ\n/5hwDzJ/BT3lADdh+Cj5unl4Cjf/36jyYiRQTtWehlP9jejFdmGN+h3O1Nr88LaMroEQTp\nqb+rgKdD9Qn26O9GHC+jPtl/GRyw1kIaZH8teqxcFgoaiWIqet/yAlkj9WcappBQPWXSWD\nhc46LtoNrxhdISJUw4hoLk6kF+gCvTxg+HFdnMzgP3uaTt30Nu50l6ObibNVA+HgfsW3X7\nNpNGQFICoz2dpXeR+W5IFc0RsetbafViOkINrh0VR4XwqT+O85SFw5uUeKdiI04zsdBuLc\n64RSi/pdhjeoigreLgnrNJdDBItr0MyGC4vxGCSzxgwXAAAAgQDM+iNHVDx7eeeArZ9QgZ\noDDIXO23QgVn0wy4fVqCA58c7jfDf38Zi+r1OAxmpOBC3uuxeFG9JCgPIaxrTMCpR/lm6A\nXTtwt7xnJng0kEb0HFo1OutA3kOnH7yFu4LePrLXGLHzLTSAItQvvkYXlC1qBzhsI+hOuo\nNy+tJZlAj8wwAAAIEAzbTSlahw6AU71OejF/Swx0Vzt1eJ+zVaWRbbXzwp8ikINWcOxx7P\nftVHoc6ym1p21ODzIy0MPJaouoFuuDkDNjppgdIRx4/7IdwsgV8WQGBmGnIkbl/LVAPiUa\n5UjwTzvihWuZWc0gV1SQ69ps/sTm//Nsz1wviy7UOe3M+eDm8AAACBALaqPXxHj1wqoEDz\nzshqIOB0lh2JdNdA0j69VV5CxBE3FxSz28yokRt8ujk6Tc7TxIq7Io3wKrwXRowfUgYifF\n21L/5v8oUIWiHhrNs2TP762AceiT/5Zcng+Lyho5LZlqhPbLJ942+EUqbLaAXd4gfaaAzJ\nULE8nem1SxH1uuoLAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQIDBAUG\n-----END OPENSSH PRIVATE KEY-----\n";
const ed25519Pkcs8Oid = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIKVlSCmmBY6Wn8YWY26XAN5zkD/vJYwCSIwsKk8brU27\n-----END PRIVATE KEY-----\n";
const sec1Ecdsa = "-----BEGIN EC PARAMETERS-----\nBggqhkjOPQMBBw==\n-----END EC PARAMETERS-----\n-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDdLFJiwPdl1iJSWyGZsKguGOMJ6RCHzktI1govWhNULoAoGCCqGSM49\nAwEHoUQDQgAE5r3AAArr/FMR+klnWbM1tZXjY5/bKZmHSUgHoAbOe513vc1qemKJ\nkHaaNfqXD5NoUNw6fuHoFkWfi7YqXSD8oA==\n-----END EC PRIVATE KEY-----\n";
test("OpenSSH ECDSA P-256 keys detect as ECDSA with keySize 256", () => {
assert.deepEqual(detectSshKeyType(ecdsaP256), { type: "ECDSA", keySize: 256 });
});
test("OpenSSH ECDSA P-384 / P-521 keys detect with matching key sizes", () => {
assert.deepEqual(detectSshKeyType(ecdsaP384), { type: "ECDSA", keySize: 384 });
assert.deepEqual(detectSshKeyType(ecdsaP521), { type: "ECDSA", keySize: 521 });
});
test("PKCS#8 ECDSA keys detect via DER OID, not plaintext hints", () => {
assert.deepEqual(detectSshKeyType(ecdsaPkcs8), { type: "ECDSA", keySize: 256 });
});
test("SEC1 EC private keys detect as ECDSA", () => {
assert.deepEqual(detectSshKeyType(sec1Ecdsa), { type: "ECDSA" });
});
test("OpenSSH and PKCS#8 Ed25519 keys stay ED25519", () => {
assert.deepEqual(detectSshKeyType(ed25519), { type: "ED25519" });
assert.deepEqual(detectSshKeyType(ed25519Pkcs8), { type: "ED25519" });
assert.deepEqual(detectSshKeyType(ed25519Pkcs8Oid), { type: "ED25519" });
});
test("OpenSSH RSA keys stay RSA", () => {
assert.deepEqual(detectSshKeyType(rsa), { type: "RSA" });
});
test("OpenSSH RSA comment cannot override the public-key algorithm", () => {
const body = rsa.split(/\r?\n/).filter((line) => !line.includes("-----")).join("");
const decoded = atob(body);
const originalComment = "runner@runnervmlun5";
const misleadingComment = "ecdsa-sha2-nistp256";
assert.equal(decoded.includes(originalComment), true);
assert.equal(originalComment.length, misleadingComment.length);
const changedBody = btoa(decoded.replace(originalComment, misleadingComment));
const key = `-----BEGIN OPENSSH PRIVATE KEY-----\n${changedBody}\n-----END OPENSSH PRIVATE KEY-----`;
assert.deepEqual(detectSshKeyType(key), { type: "RSA" });
});
test("public key algorithm prefix is used as a fallback", () => {
assert.deepEqual(
detectSshKeyType("not a key", "ecdsa-sha2-nistp256 AAAAB2 user@host"),
{ type: "ECDSA", keySize: 256 },
);
assert.deepEqual(detectSshKeyType(undefined, "ssh-ed25519 AAAAC3 user@host"), {
type: "ED25519",
});
assert.deepEqual(detectSshKeyType(undefined, "ssh-rsa AAAAB3 user@host"), {
type: "RSA",
});
});
test("unrecognizable content defaults to ED25519 (ssh-keygen default)", () => {
assert.deepEqual(detectSshKeyType("random text"), { type: "ED25519" });
assert.deepEqual(detectSshKeyType(undefined, undefined), { type: "ED25519" });
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Pure SSH key-type detection for imported keys.
*
* Modern OpenSSH private keys ("-----BEGIN OPENSSH PRIVATE KEY-----") store the
* algorithm name inside the base64 body (RFC 5656 / PROTOCOL.key), so text-only
* heuristics on the PEM armor misdetect ECDSA keys as ED25519. Decode the base64
* body and read its public-key algorithm field; fall back to DER OIDs for
* PKCS#8 / SEC1 PEM bodies, then to the public key's algorithm prefix.
*/
import type { KeyType } from './models';
export interface SshKeyTypeInfo {
type: KeyType;
/** RSA: 4096/2048/1024, ECDSA: 521/384/256 */
keySize?: number;
}
interface CurveInfo {
name: string;
keySize: number;
}
const ECDSA_CURVES: CurveInfo[] = [
{ name: 'ecdsa-sha2-nistp256', keySize: 256 },
{ name: 'ecdsa-sha2-nistp384', keySize: 384 },
{ name: 'ecdsa-sha2-nistp521', keySize: 521 },
];
const OPENSSH_ARMOR = 'openssh private key';
// DER OID fragments (id-RSAEncryption 1.2.840.113549.1.1.1, id-ecPublicKey
// 1.2.840.10045.2.1, id-Ed25519 1.3.101.112) as raw byte strings, matched
// against the base64-decoded PEM body.
const RSA_OID = '\u{2a}\u{86}\u{48}\u{86}\u{f7}\u{0d}\u{01}\u{01}\u{01}';
const EC_PUBLIC_KEY_OID = '\u{2a}\u{86}\u{48}\u{ce}\u{3d}\u{02}\u{01}';
const ED25519_OID = '\u{2b}\u{65}\u{70}';
// Named-curve OIDs for id-ecPublicKey parameters (secp256r1/secp384r1/secp521r1).
const EC_CURVE_OIDS: Array<{ oid: string; keySize: number }> = [
{ oid: '\u{2a}\u{86}\u{48}\u{ce}\u{3d}\u{03}\u{01}\u{07}', keySize: 256 },
{ oid: '\u{2b}\u{81}\u{04}\u{00}\u{22}', keySize: 384 },
{ oid: '\u{2b}\u{81}\u{04}\u{00}\u{23}', keySize: 521 },
];
const decodeBase64Body = (pem: string): string | undefined => {
const body = pem
.split(/\r?\n/)
.filter((line) => !line.includes('-----'))
.join('')
.replace(/\s+/g, '');
if (!body) return undefined;
try {
return atob(body);
} catch {
return undefined;
}
};
const readSshString = (binary: string, offset: number): { value: string; end: number } | undefined => {
if (offset + 4 > binary.length) return undefined;
const length = binary.charCodeAt(offset) * 0x1000000
+ binary.charCodeAt(offset + 1) * 0x10000
+ binary.charCodeAt(offset + 2) * 0x100
+ binary.charCodeAt(offset + 3);
const end = offset + 4 + length;
if (end > binary.length) return undefined;
return { value: binary.slice(offset + 4, end), end };
};
const detectFromOpenSshBody = (binary: string): SshKeyTypeInfo | undefined => {
const magic = 'openssh-key-v1\0';
if (!binary.startsWith(magic)) return undefined;
let offset = magic.length;
for (let i = 0; i < 3; i++) {
const field = readSshString(binary, offset); // cipher, KDF, KDF options
if (!field) return undefined;
offset = field.end;
}
if (offset + 4 > binary.length || binary.slice(offset, offset + 4) === '\0\0\0\0') {
return undefined; // no public keys
}
const publicKey = readSshString(binary, offset + 4);
const algorithm = publicKey && readSshString(publicKey.value, 0)?.value;
if (!algorithm) return undefined;
for (const curve of ECDSA_CURVES) {
if (algorithm === curve.name) {
return { type: 'ECDSA', keySize: curve.keySize };
}
}
if (algorithm === 'ssh-ed25519') return { type: 'ED25519' };
if (algorithm === 'ssh-rsa') return { type: 'RSA' };
return undefined;
};
const detectFromDerBody = (binary: string): SshKeyTypeInfo | undefined => {
if (binary.includes(ED25519_OID)) return { type: 'ED25519' };
if (binary.includes(RSA_OID)) return { type: 'RSA' };
if (binary.includes(EC_PUBLIC_KEY_OID)) {
for (const curve of EC_CURVE_OIDS) {
if (binary.includes(curve.oid)) {
return { type: 'ECDSA', keySize: curve.keySize };
}
}
return { type: 'ECDSA' };
}
return undefined;
};
const detectFromPrivateKey = (privateKey: string): SshKeyTypeInfo | undefined => {
const pk = privateKey.toLowerCase();
if (pk.includes(OPENSSH_ARMOR)) {
const binary = decodeBase64Body(privateKey);
const fromBlob = binary ? detectFromOpenSshBody(binary) : undefined;
if (fromBlob) return fromBlob;
}
// PKCS#8 / SEC1 PEM bodies have no plaintext algorithm hints; look for DER OIDs.
if (pk.includes('begin ') && pk.includes('private key')) {
const binary = decodeBase64Body(privateKey);
const fromDer = binary ? detectFromDerBody(binary) : undefined;
if (fromDer) return fromDer;
}
// Last-resort plaintext heuristics (legacy PEM armor, PPK comments, ...).
if (pk.includes('rsa')) return { type: 'RSA' };
if (pk.includes('ecdsa') || pk.includes('ec ') || pk.includes('ec private')) {
return { type: 'ECDSA' };
}
if (pk.includes('ed25519')) return { type: 'ED25519' };
return undefined;
};
const detectFromPublicKey = (publicKey: string): SshKeyTypeInfo | undefined => {
const algorithm = publicKey.trim().split(/\s+/)[0]?.toLowerCase();
if (!algorithm) return undefined;
for (const curve of ECDSA_CURVES) {
if (algorithm.includes(curve.name)) {
return { type: 'ECDSA', keySize: curve.keySize };
}
}
if (algorithm.includes('ed25519')) return { type: 'ED25519' };
if (algorithm.includes('rsa')) return { type: 'RSA' };
return undefined;
};
/**
* Detect the key type (and key size when known) for an imported SSH key.
* Private-key evidence wins; the public key is a fallback; defaults to ED25519
* (ssh-keygen's default) when nothing is recognizable.
*/
export const detectSshKeyType = (
privateKey?: string | null,
publicKey?: string | null,
): SshKeyTypeInfo => {
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
if (fromPrivate) return fromPrivate;
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
return fromPublic ?? { type: 'ED25519' };
};
-6
View File
@@ -158,12 +158,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.vault.showOnlyUngroupedHostsInRootDesc",
sectionKey: "settings.vault.title",
},
{
id: "appearance-tab-bar-position",
tab: "appearance",
labelKey: "settings.appearance.tabBarPosition",
sectionKey: "settings.vault.title",
},
{
id: "appearance-vault-show-sftp-tab",
tab: "appearance",
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "./models.ts";
import { isSameSftpHostSelection, resolveTerminalSftpHost } from "./sftpTerminalIdentity.ts";
const host = (id: string, username: string): Host => ({
id, label: id, hostname: `${id}.example.test`, port: 22, username,
tags: [], os: "linux",
});
test("SFTP target changes when a focused session uses another saved identity", () => {
const root = host("shared", "root");
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
const alternateDeploy = { ...deploy, identityId: "alternate-deploy-id" };
assert.equal(isSameSftpHostSelection(root, deploy), false);
assert.equal(isSameSftpHostSelection(deploy, alternateDeploy), false);
assert.equal(isSameSftpHostSelection(deploy, { ...deploy }), true);
assert.equal(isSameSftpHostSelection(deploy, { ...deploy, port: 2222 }), false);
});
test("SFTP follows each focused terminal's authenticated identity across focus changes", () => {
const root = host("shared", "root");
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
const other = host("other", "alice");
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: root, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: other, storedHost: deploy, authenticatedHost: undefined, followsTerminal: true,
}), other);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: false,
}), root);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, authenticatedHost: { ...deploy, port: 2222 }, followsTerminal: true,
}), root);
});
+35
View File
@@ -0,0 +1,35 @@
import type { Host } from "./models";
/** Compare the selected SFTP target, including the temporary login identity. */
export function isSameSftpHostSelection(left: Host | null | undefined, right: Host | null | undefined): boolean {
if (!left || !right) return false;
return left.id === right.id
&& left.hostname === right.hostname
&& left.port === right.port
&& left.protocol === right.protocol
&& left.username === right.username
&& left.identityId === right.identityId
&& left.sftpSudo === right.sftpSudo
&& (left.sftpFileProtocol || "auto") === (right.sftpFileProtocol || "auto");
}
/** Keep a terminal's authenticated identity when its SFTP panel follows that terminal. */
export function resolveTerminalSftpHost({
sessionHost,
storedHost,
authenticatedHost,
followsTerminal,
}: {
sessionHost?: Host | null;
storedHost?: Host | null;
authenticatedHost?: Host | null;
followsTerminal: boolean;
}): Host | null {
if (followsTerminal && sessionHost && authenticatedHost
&& authenticatedHost.id === sessionHost.id
&& authenticatedHost.hostname === sessionHost.hostname
&& (authenticatedHost.port || 22) === (sessionHost.port || 22)) {
return authenticatedHost;
}
return sessionHost ?? storedHost ?? null;
}
+14
View File
@@ -7,9 +7,11 @@ import {
closeSidePanelPane,
collectSidePanelPanes,
createSidePanelLayout,
cycleSidePanelDockPosition,
focusSidePanelPane,
getSidePanelNodeMinimumPixels,
getSidePanelSplitResizeBounds,
isSidePanelDockPosition,
resizeSidePanelSplit,
selectSidePanelTool,
sidePanelLayoutHasTool,
@@ -166,3 +168,15 @@ test('the pane limit rejects additional splits without changing the layout', ()
assert.equal(collectSidePanelPanes(duplicateAttempt.root).length, 7);
assert.equal(duplicateAttempt.focusedPaneId, 'pane-0');
});
test('dock position cycles through left, right and bottom', () => {
assert.equal(cycleSidePanelDockPosition('left'), 'right');
assert.equal(cycleSidePanelDockPosition('right'), 'bottom');
assert.equal(cycleSidePanelDockPosition('bottom'), 'left');
});
test('dock position validator accepts only known positions', () => {
assert.equal(isSidePanelDockPosition('bottom'), true);
assert.equal(isSidePanelDockPosition('top'), false);
assert.equal(isSidePanelDockPosition(42), false);
});
+20
View File
@@ -249,3 +249,23 @@ export function resizeSidePanelSplit(
const root = patch(layout.root);
return root === layout.root ? layout : { ...layout, root };
}
export type SidePanelDockPosition = 'left' | 'right' | 'bottom';
export const SIDE_PANEL_DOCK_POSITIONS: readonly SidePanelDockPosition[] = [
'left',
'right',
'bottom',
];
export function isSidePanelDockPosition(value: unknown): value is SidePanelDockPosition {
return typeof value === 'string'
&& (SIDE_PANEL_DOCK_POSITIONS as readonly string[]).includes(value);
}
export function cycleSidePanelDockPosition(
position: SidePanelDockPosition,
): SidePanelDockPosition {
const index = SIDE_PANEL_DOCK_POSITIONS.indexOf(position);
return SIDE_PANEL_DOCK_POSITIONS[(index + 1) % SIDE_PANEL_DOCK_POSITIONS.length];
}
-1
View File
@@ -339,7 +339,6 @@ export interface SyncPayload {
// Vault: root list shows only ungrouped hosts
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
tabBarPosition?: 'top' | 'bottom';
showSftpTab?: boolean;
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
shellOnlyTabNumberShortcuts?: boolean;
+26
View File
@@ -271,6 +271,32 @@ test("withSyncReliabilityMeta carries old deletion records until the entity is r
assert.deepEqual(recreated.syncMeta?.deletions, []);
});
test("withSyncReliabilityMeta inherits deletion records from the stored base", () => {
const deletedHost = {
id: "host-1",
label: "Old",
hostname: "old.example.com",
username: "root",
tags: [],
os: "linux" as const,
};
const base = withSyncReliabilityMeta(
payload(),
payload({ hosts: [deletedHost] }),
{ deviceId: "device-a", now: 100 },
);
const next = withSyncReliabilityMeta(payload(), base, { deviceId: "device-b", now: 200 });
assert.deepEqual(next.syncMeta?.deletions, base.syncMeta?.deletions);
const recreated = withSyncReliabilityMeta(
payload({ hosts: [deletedHost] }),
base,
{ deviceId: "device-b", now: 300 },
);
assert.deepEqual(recreated.syncMeta?.deletions, []);
});
test("withSyncReliabilityMeta attaches change summary and deletion records", () => {
const base = payload({
snippets: [{ id: "snippet-1", label: "Old", command: "ls" }],
+7 -4
View File
@@ -286,10 +286,13 @@ export function withSyncReliabilityMeta(
const changeSummary = summarizeSyncChanges(base, payload);
const deletions = mergeDeletionRecords(
payload,
collectSyncDeletions(base, payload, {
deletedAt: generatedAt,
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
}),
[
...(base?.syncMeta?.deletions ?? []),
...collectSyncDeletions(base, payload, {
deletedAt: generatedAt,
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
}),
],
);
const meta: SyncReliabilityMeta = {
schemaVersion: 1,
-5
View File
@@ -1,5 +0,0 @@
export type TabBarPosition = 'top' | 'bottom';
export function normalizeTabBarPosition(value: unknown): TabBarPosition {
return value === 'bottom' ? 'bottom' : 'top';
}
+42 -10
View File
@@ -78,7 +78,7 @@ function parseClaudeSettings(settings) {
return str;
}
const CLAUDE_REASONING_LEVELS = new Set(["low", "medium", "high", "max"]);
const CLAUDE_REASONING_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
function splitClaudeModelSelection(model) {
if (typeof model !== "string" || !model) {
@@ -286,15 +286,47 @@ async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn })
/** Map claude-agent-sdk ModelInfo[] -> renderer preset shape {id,name,description}. */
function mapClaudeModels(models) {
if (!Array.isArray(models)) return [];
return models
.filter((m) => m && m.value)
.map((m) => ({
id: m.value,
name: m.displayName || m.value,
description: m.description,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
}));
// SDK types declare {value, displayName, description}, but Claude Code's
// supportedModels() control response actually returns {id, name} at runtime
// (same CLI lineage as CodeBuddy — see mapCodebuddyModels). Accept both
// shapes so a live catalog is never filtered into an empty list, which
// would silently degrade the picker to build-time curated presets (#3496).
const presets = models
.map((m) => {
if (!m) return null;
const id = m.value || m.id || m.modelId;
if (!id) return null;
const advertisedLevels = m.supportedEffortLevels || m.thinking?.effort_options;
const thinkingLevels = m.supportsEffort === false
? []
: Array.isArray(advertisedLevels)
? advertisedLevels.filter((level) => CLAUDE_REASONING_LEVELS.has(level))
: ["low", "medium", "high", "max"];
return {
id,
name: m.displayName || m.name || id,
description: m.description,
thinkingLevels,
...(thinkingLevels.length > 0 ? { defaultThinkingLevel: thinkingLevels.includes("medium") ? "medium" : thinkingLevels[0] } : {}),
};
})
.filter(Boolean);
const nameCounts = new Map();
for (const preset of presets) {
nameCounts.set(preset.name, (nameCounts.get(preset.name) || 0) + 1);
}
const usedNames = new Set(presets.map((preset) => preset.name));
return presets.map((preset) => {
if (nameCounts.get(preset.name) === 1) return preset;
let suffix = 1;
let name = `[${preset.id}] ${preset.name}`;
while (usedNames.has(name)) {
suffix += 1;
name = `[${preset.id} #${suffix}] ${preset.name}`;
}
usedNames.add(name);
return { ...preset, name };
});
}
/**
@@ -147,8 +147,84 @@ test("mapClaudeModels maps {value,displayName,description} -> {id,name,descripti
assert.deepEqual(mapClaudeModels(null), []);
});
test("mapClaudeModels accepts the CLI's runtime {id,name} shape (Claude Code 2.x)", () => {
const out = mapClaudeModels([
{ id: "claude-opus-5-5", name: "Opus 5.5" },
{ modelId: "claude-sonnet-5", name: "Sonnet 5", description: "Everyday tasks" },
{ id: "claude-haiku-4-5" },
{ name: "no id -> dropped" },
]);
assert.deepEqual(out, [
{
id: "claude-opus-5-5",
name: "Opus 5.5",
description: undefined,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
{
id: "claude-sonnet-5",
name: "Sonnet 5",
description: "Everyday tasks",
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
{
id: "claude-haiku-4-5",
name: "claude-haiku-4-5",
description: undefined,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
]);
});
test("mapClaudeModels distinguishes Claude aliases that share a custom model name", () => {
const models = mapClaudeModels([
{ id: "default", name: "Default (recommended)" },
{ id: "opus", name: "deepseek-v4.1-flash:cloud" },
{ id: "sonnet", name: "deepseek-v4.1-flash:cloud" },
{ id: "haiku", name: "Claude Haiku" },
]);
assert.deepEqual(models.map(({ id, name }) => ({ id, name })), [
{ id: "default", name: "Default (recommended)" },
{ id: "opus", name: "[opus] deepseek-v4.1-flash:cloud" },
{ id: "sonnet", name: "[sonnet] deepseek-v4.1-flash:cloud" },
{ id: "haiku", name: "Claude Haiku" },
]);
});
test("mapClaudeModels keeps names unique when an existing name matches a generated alias", () => {
const models = mapClaudeModels([
{ id: "opus", name: "Custom" },
{ id: "sonnet", name: "Custom" },
{ id: "other", name: "[opus] Custom" },
{ id: "opus", name: "Custom" },
]);
assert.deepEqual(models.map(({ name }) => name), [
"[opus #2] Custom",
"[sonnet] Custom",
"[opus] Custom",
"[opus #3] Custom",
]);
});
test("mapClaudeModels preserves each live model's supported effort levels", () => {
const out = mapClaudeModels([
{ value: "opus", displayName: "Opus", supportsEffort: true, supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"] },
{ id: "fable", name: "Fable", thinking: { effort_options: ["high", "xhigh"] } },
{ value: "fast", displayName: "Fast", supportsEffort: false },
]);
assert.deepEqual(out.map((model) => [model.id, model.thinkingLevels, model.defaultThinkingLevel]), [
["opus", ["low", "medium", "high", "xhigh", "max"], "medium"],
["fable", ["high", "xhigh"], "high"],
["fast", [], undefined],
]);
});
test("splitClaudeModelSelection only treats known trailing effort as thinking", () => {
assert.deepEqual(splitClaudeModelSelection("sonnet/high"), { model: "sonnet", effort: "high" });
assert.deepEqual(splitClaudeModelSelection("opus/xhigh"), { model: "opus", effort: "xhigh" });
assert.deepEqual(splitClaudeModelSelection("claude-opus-4-6"), {
model: "claude-opus-4-6",
effort: undefined,
@@ -169,6 +245,9 @@ test("buildClaudeQueryOptions splits model/effort into model + settings.effort",
assert.equal(opts.model, "sonnet");
assert.equal(opts.effort, "high");
assert.deepEqual(opts.settings, { model: "sonnet", effort: "high" });
const xhigh = buildClaudeQueryOptions({ cwd: "/tmp", model: "opus/xhigh", env: {} });
assert.equal(xhigh.model, "opus");
assert.equal(xhigh.effort, "xhigh");
});
test("parseClaudeSettings: path string, inline JSON object, empty, and bad JSON", () => {
@@ -32,6 +32,18 @@ const GROK_REASONING_EFFORTS = new Set([
"max",
]);
const GROK_REASONING_FALLBACKS = Object.freeze({
// Live catalogs may omit reasoning metadata for newer models; reuse the
// 4.6 family's effort levels so grok-4.7 stays effort-selectable (#3496).
"grok-4.7": {
name: "Grok 4.7",
thinkingLevels: ["xhigh", "high", "medium", "low"],
defaultThinkingLevel: "high",
},
"grok-4.7-build-fast": {
name: "Grok 4.7 Build Fast",
thinkingLevels: ["xhigh", "high", "medium", "low"],
defaultThinkingLevel: "high",
},
"grok-4.5": {
name: "Grok 4.5",
thinkingLevels: ["high", "medium", "low"],
+3 -1
View File
@@ -75,7 +75,9 @@ const DRIVER_REGISTRY = {
signal: ctx.signal,
});
},
// codex-sdk exposes no model catalog; the UI falls back to curated presets.
// codex-sdk exposes no model catalog. sdkStreamHandlers falls back to the
// App Server runtime's live `model/list` when this returns empty (#3496);
// curated presets remain the last resort in the renderer.
async listModels() { return []; },
},
copilot: {
@@ -79,6 +79,11 @@ const SDK_MODEL_CACHE_ENV_KEYS = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"CODEX_HOME",
"CLAUDE_CONFIG_DIR",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"OPENCODE_BIN",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
@@ -139,6 +144,47 @@ function normalizeSdkListModelsResult(raw) {
return { currentModelId, models };
}
/**
* Fetch a runtime model catalog for one SDK backend. For Codex on the default
* `sdk` runtime, the codex-sdk exposes no model catalog (its driver always
* returns []), so fall back to the App Server runtime's live `model/list`
* catalog — otherwise the picker stays pinned to build-time curated presets
* and cannot follow the installed CLI (#3496).
*/
async function fetchSdkModelCatalog({
backendKey,
codexRuntime,
driver,
binPath,
env,
abortController,
cursorAuthMode,
cursorCliBinPath,
codexAppServerRuntime: appServerRuntime,
}) {
let raw;
if (codexRuntime === "app-server") {
raw = await appServerRuntime.listModels({ binPath, env });
} else {
raw = await driver.listModels({
binPath,
env,
abortController,
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
});
const sdkCatalog = normalizeSdkListModelsResult(raw);
if (backendKey === "codex" && sdkCatalog.models.length === 0 && !sdkCatalog.currentModelId) {
raw = await appServerRuntime.listModels({ binPath, env });
}
}
const { currentModelId, models } = normalizeSdkListModelsResult(raw);
if (models.length === 0 && !currentModelId) {
throw new Error("The live model catalog returned no models");
}
return raw;
}
function resolveSdkPromptPlacement({
backendKey,
turnPrompt,
@@ -888,9 +934,10 @@ function registerSdkStreamHandlers(ctx) {
const codexRuntime = backendKey === "codex" && requestedCodexRuntime === "app-server"
? "app-server"
: "sdk";
// claude/copilot/opencode enumerate models via the SDK; codex has no
// catalog (its driver returns []), so the renderer falls back to curated
// presets. Cache + in-flight coalescing avoid spawn storms (#2184).
// claude/copilot/opencode enumerate models via the SDK; codex's sdk
// driver returns [] and falls back to the App Server catalog (see
// fetchSdkModelCatalog). Cache + in-flight coalescing avoid spawn
// storms (#2184).
const cacheKey = buildSdkModelCacheKey(
backendKey,
cursorAuthMode === "cli-login" ? (cursorCliBinPath || binPath) : binPath,
@@ -910,15 +957,17 @@ function registerSdkStreamHandlers(ctx) {
const abortController = new AbortController();
try {
const raw = await withTimeout(
codexRuntime === "app-server"
? codexAppServerRuntime.listModels({ binPath, env })
: driver.listModels({
binPath,
env,
abortController,
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
}),
fetchSdkModelCatalog({
backendKey,
codexRuntime,
driver,
binPath,
env,
abortController,
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
codexAppServerRuntime,
}),
MODEL_LIST_TIMEOUT_MS,
abortController,
);
@@ -938,7 +987,7 @@ function registerSdkStreamHandlers(ctx) {
ok: true,
currentModelId: null,
models: [],
warning: codexRuntime === "app-server" ? (err?.message || String(err)) : undefined,
warning: err?.message || String(err),
};
}
})();
@@ -958,9 +1007,7 @@ function registerSdkStreamHandlers(ctx) {
ok: true,
currentModelId: null,
models: [],
warning: backendKey === "codex" && requestedCodexRuntime === "app-server"
? (err?.message || String(err))
: undefined,
warning: err?.message || String(err),
};
}
});
@@ -1233,6 +1280,7 @@ module.exports = {
expireSiblingCursorCliModeSessions,
expireSiblingGrokRuntimeSessions,
shouldCacheSdkRuntimeModels,
fetchSdkModelCatalog,
normalizeHistoryMessages,
formatSdkHistoryReplaySection,
buildSdkTurnPrompt,
@@ -17,6 +17,7 @@ const {
resolveBackendKey,
resolveSdkBackendBinPath,
shouldCacheSdkRuntimeModels,
fetchSdkModelCatalog,
} = require("./sdkStreamHandlers.cjs");
/**
@@ -127,6 +128,23 @@ test("SDK model cache keys include catalog-affecting agent environment", () => {
buildSdkModelCacheKey("cursor", "/usr/bin/cursor", { CURSOR_API_KEY: "very-secret-key" }),
/very-secret-key/,
);
assert.notEqual(
buildSdkModelCacheKey("codex", "/usr/bin/codex", { HOME: "/shared", CODEX_HOME: "/profiles/a" }),
buildSdkModelCacheKey("codex", "/usr/bin/codex", { HOME: "/shared", CODEX_HOME: "/profiles/b" }),
);
assert.notEqual(
buildSdkModelCacheKey("claude", "/usr/bin/claude", { HOME: "/shared", CLAUDE_CONFIG_DIR: "/profiles/a" }),
buildSdkModelCacheKey("claude", "/usr/bin/claude", { HOME: "/shared", CLAUDE_CONFIG_DIR: "/profiles/b" }),
);
});
test("Claude model cache keys separate authentication without exposing values", () => {
for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) {
const first = buildSdkModelCacheKey("claude", "/usr/bin/claude", { [name]: "secret-one" });
const second = buildSdkModelCacheKey("claude", "/usr/bin/claude", { [name]: "secret-two" });
assert.notEqual(first, second, `${name} must separate cache entries`);
assert.doesNotMatch(first, /secret-one/);
}
});
test("SDK model cache removes expired entries instead of retaining tombstones", () => {
@@ -164,6 +182,101 @@ test("normalizeSdkListModelsResult preserves current model ids from object resul
});
});
test("fetchSdkModelCatalog falls back to the App Server catalog when codex-sdk returns empty", async () => {
const appServerCatalog = {
currentModelId: "gpt-5.6-sol/high",
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
};
const appServerRuntime = { listModels: async ({ binPath, env }) => ({ calledWith: binPath, profile: env.CODEX_HOME, ...appServerCatalog }) };
const codexDriver = { listModels: async () => [] };
// codex sdk runtime: empty catalog triggers the App Server fallback (#3496).
const codexFallback = await fetchSdkModelCatalog({
backendKey: "codex",
codexRuntime: "sdk",
driver: codexDriver,
binPath: "/cli/codex",
env: { CODEX_HOME: "/profiles/b" },
codexAppServerRuntime: appServerRuntime,
});
assert.equal(codexFallback.calledWith, "/cli/codex");
assert.equal(codexFallback.profile, "/profiles/b");
assert.deepEqual(normalizeSdkListModelsResult(codexFallback), appServerCatalog);
// Non-empty sdk catalog or app-server runtime: no fallback round-trip.
let appServerCalls = 0;
const countingRuntime = {
listModels: async () => {
appServerCalls += 1;
return appServerCatalog;
},
};
await fetchSdkModelCatalog({
backendKey: "codex",
codexRuntime: "sdk",
driver: { listModels: async () => [{ id: "gpt-5.6-sol" }] },
binPath: "/cli/codex",
env: {},
codexAppServerRuntime: countingRuntime,
});
assert.equal(appServerCalls, 0);
await fetchSdkModelCatalog({
backendKey: "codex",
codexRuntime: "app-server",
driver: codexDriver,
binPath: "/cli/codex",
env: {},
codexAppServerRuntime: countingRuntime,
});
assert.equal(appServerCalls, 1);
// Non-codex backends never touch the App Server runtime.
let driverCalls = 0;
await fetchSdkModelCatalog({
backendKey: "claude",
codexRuntime: "sdk",
driver: {
listModels: async (args) => {
driverCalls += 1;
assert.equal(args.cursorAuthMode, undefined);
return [{ id: "claude-opus-5-5", name: "Opus 5.5" }];
},
},
binPath: "/cli/claude",
env: {},
codexAppServerRuntime: countingRuntime,
});
assert.equal(driverCalls, 1);
assert.equal(appServerCalls, 1);
});
test("empty or failed live catalogs surface a failure for the warning path", async () => {
const base = {
binPath: "/cli/agent",
env: {},
driver: { listModels: async () => [] },
codexAppServerRuntime: { listModels: async () => [] },
};
await assert.rejects(
fetchSdkModelCatalog({ ...base, backendKey: "claude", codexRuntime: "sdk" }),
/returned no models/,
);
await assert.rejects(
fetchSdkModelCatalog({ ...base, backendKey: "codex", codexRuntime: "sdk" }),
/returned no models/,
);
await assert.rejects(
fetchSdkModelCatalog({
...base,
backendKey: "codex",
codexRuntime: "sdk",
codexAppServerRuntime: { listModels: async () => { throw new Error("model/list unavailable"); } },
}),
/model\/list unavailable/,
);
});
test("CodeBuddy and OpenCode keep Netcatty context in the system prompt only", () => {
const input = {
turnPrompt: "user request",
+4 -1
View File
@@ -16,7 +16,10 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) throw error;
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
let input;
@@ -0,0 +1,83 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
function makeTempDir(prefix) {
return fs.mkdtempSync(`${tempDirBridge.getTempFilePath(prefix)}-`);
}
function stubLink(impl) {
const original = fs.promises.link;
fs.promises.link = impl;
return () => { fs.promises.link = original; };
}
test("publishLocalFileExclusive falls back to copy when hardlink fails with EISDIR", async () => {
const dir = makeTempDir("netcatty-publish-eisdir-");
try {
const source = path.join(dir, "staged");
const target = path.join(dir, "target");
fs.writeFileSync(source, "hello hardlink fallback");
const restore = stubLink(async () => {
// libuv maps Win32 ERROR_INVALID_FUNCTION on exFAT/FAT32 to EISDIR.
const error = new Error("EISDIR: illegal operation on a directory, link 'src' -> 'dest'");
error.code = "EISDIR";
throw error;
});
try {
const identity = await publishLocalFileExclusive(source, target);
const stat = fs.lstatSync(target);
assert.equal(fs.readFileSync(target, "utf8"), "hello hardlink fallback");
assert.equal(stat.isFile(), true);
assert.equal(identity.dev, stat.dev);
assert.equal(identity.ino, stat.ino);
assert.equal(identity.size, stat.size);
// The prepared source still exists for the caller to unlink.
assert.equal(fs.existsSync(source), true);
} finally {
restore();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("publishLocalFileExclusive still hardlinks on volumes that support it", async () => {
const dir = makeTempDir("netcatty-publish-link-");
try {
const source = path.join(dir, "staged");
const target = path.join(dir, "target");
fs.writeFileSync(source, "hardlinked publish");
const identity = await publishLocalFileExclusive(source, target);
const [sourceStat, targetStat] = [fs.lstatSync(source), fs.lstatSync(target)];
assert.equal(sourceStat.ino, targetStat.ino);
assert.deepEqual(identity, { dev: targetStat.dev, ino: targetStat.ino, size: targetStat.size });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("publishLocalFileExclusive rethrows unexpected link errors", async () => {
const dir = makeTempDir("netcatty-publish-eexist-");
try {
const source = path.join(dir, "staged");
const target = path.join(dir, "target");
fs.writeFileSync(source, "unrelated failure");
const restore = stubLink(async () => {
const error = new Error("EEXIST: file already exists");
error.code = "EEXIST";
throw error;
});
try {
await assert.rejects(() => publishLocalFileExclusive(source, target), { code: "EEXIST" });
} finally {
restore();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+60 -1
View File
@@ -50,6 +50,8 @@ let externalAuthToken = null;
let pendingHostStart = null; // { promise, server, cancel }
let electronModule = null;
let cliDiscoveryFilePath = getCliDiscoveryFilePath();
let discoverySelfHealTimer = null;
const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
@@ -448,7 +450,55 @@ function removeCliDiscoveryFile() {
}
}
function isCliDiscoveryFileCurrent() {
if (!cliDiscoveryFilePath) return true;
let raw;
try {
raw = fs.readFileSync(cliDiscoveryFilePath, "utf8");
} catch {
return false;
}
try {
const parsed = JSON.parse(raw);
return parsed?.port === tcpPort
&& parsed?.token === authToken
&& parsed?.pid === process.pid;
} catch {
return false;
}
}
// Self-heal: the discovery file is a runtime pointer that can be deleted
// out-of-band (cleanup tools, agent shells) while the TCP bridge is still
// listening. Rewrite it from the in-memory port/token so tool CLI does not
// report a misleading APP_NOT_RUNNING / "sessions gone" error.
function ensureCliDiscoveryFile() {
if (!tcpPort || !authToken || !cliDiscoveryFilePath) return;
if (isCliDiscoveryFileCurrent()) return;
debugLog("self-healing CLI discovery file");
writeCliDiscoveryFile();
}
function startDiscoverySelfHeal() {
stopDiscoverySelfHeal();
discoverySelfHealTimer = setInterval(() => {
try {
ensureCliDiscoveryFile();
} catch {
// Self-heal is best-effort.
}
}, DISCOVERY_SELF_HEAL_INTERVAL_MS);
if (typeof discoverySelfHealTimer.unref === "function") discoverySelfHealTimer.unref();
}
function stopDiscoverySelfHeal() {
if (!discoverySelfHealTimer) return;
clearInterval(discoverySelfHealTimer);
discoverySelfHealTimer = null;
}
function shutdownHost({ preserveScopedMetadata = false } = {}) {
stopDiscoverySelfHeal();
removeCliDiscoveryFile();
authToken = null;
if (pendingHostStart?.server && pendingHostStart.server !== tcpServer) {
@@ -1124,7 +1174,11 @@ function checkCommandSafetyCommonOnly(command) {
// ── TCP Server ──
function getOrCreateHost() {
if (tcpServer && tcpPort) return Promise.resolve(tcpPort);
if (tcpServer && tcpPort) {
// Host is alive; repair the discovery file if it was removed out-of-band.
ensureCliDiscoveryFile();
return Promise.resolve(tcpPort);
}
if (pendingHostStart?.promise) return pendingHostStart.promise;
// Generate a random auth token for this server instance
@@ -1183,6 +1237,7 @@ function getOrCreateHost() {
tcpServer = server;
debugLog("TCP server listening", { port: tcpPort });
writeCliDiscoveryFile();
startDiscoverySelfHeal();
try {
externalMcpHostReadyHook?.({ port: tcpPort, token: authToken });
} catch {
@@ -2125,6 +2180,9 @@ async function handleGetContext(params) {
}
function handleGetStatus() {
// Repair the discovery pointer before reporting on it, so a mid-session
// out-of-band deletion is visible to callers as already self-healed.
ensureCliDiscoveryFile();
return {
ok: true,
environment: "netcatty-terminal",
@@ -2275,6 +2333,7 @@ module.exports = {
handleReadAttachment,
getScopedSessionIds,
getOrCreateHost,
ensureCliDiscoveryFile,
buildMcpServerConfig,
activePtyExecs,
cancelBackgroundJobsForSession,
@@ -0,0 +1,77 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const bridge = require("./mcpServerBridge.cjs");
function readDiscovery(discoveryPath) {
return JSON.parse(fs.readFileSync(discoveryPath, "utf8"));
}
test("self-heals a deleted discovery file while the TCP host stays alive", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
const port = await bridge.getOrCreateHost();
assert.ok(Number.isInteger(port) && port > 0);
assert.ok(fs.existsSync(discoveryPath));
const original = readDiscovery(discoveryPath);
assert.equal(original.port, port);
assert.equal(original.pid, process.pid);
assert.ok(original.token);
// Simulate an out-of-band deletion while the host is still listening.
fs.rmSync(discoveryPath, { force: true });
assert.ok(!fs.existsSync(discoveryPath));
// The host reuse path repairs the file immediately from in-memory state.
const portAgain = await bridge.getOrCreateHost();
assert.equal(portAgain, port);
assert.ok(fs.existsSync(discoveryPath));
const repaired = readDiscovery(discoveryPath);
assert.equal(repaired.port, port);
assert.equal(repaired.token, original.token);
assert.equal(repaired.pid, process.pid);
// A stale file (wrong port/token) is rewritten too.
fs.writeFileSync(discoveryPath, JSON.stringify({ port: 1, token: "stale", pid: 999 }));
bridge.ensureCliDiscoveryFile();
const fixed = readDiscovery(discoveryPath);
assert.equal(fixed.port, port);
assert.equal(fixed.token, original.token);
});
test("ensureCliDiscoveryFile does nothing while the host is down", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
bridge.ensureCliDiscoveryFile();
assert.ok(!fs.existsSync(discoveryPath));
const port = await bridge.getOrCreateHost();
bridge.cleanup();
// cleanup removes the file and the host is gone; ensure must not revive it.
bridge.ensureCliDiscoveryFile();
assert.ok(!fs.existsSync(discoveryPath));
// A fresh host can still be started after cleanup and rewrites discovery.
const newPort = await bridge.getOrCreateHost();
assert.ok(Number.isInteger(newPort) && newPort > 0);
// Note: newPort may legitimately equal port (OS can reuse the ephemeral port
// once the old listener closed), so don't assert they differ.
assert.equal(readDiscovery(discoveryPath).port, newPort);
});
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more