Compare commits

..
Author SHA1 Message Date
bincxz 07d1a6f2c1 fix(sftp): focus the safe conflict action on open 2026-09-25 15:24:09 +08:00
309 changed files with 1124 additions and 13612 deletions

No files matched your search

+2 -6
View File
@@ -3,7 +3,7 @@ import type React from 'react';
import type { Host, HostProtocol, TerminalSession } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -535,11 +535,7 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
try {
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
allowSshProbe = shouldProbeSessionCwd({
isNetworkDevice: false,
remoteSshVersion: info?.remoteSshVersion,
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
});
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
} catch {
allowSshProbe = false;
}
@@ -28,3 +28,35 @@ 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');
}
});
+30 -20
View File
@@ -70,23 +70,31 @@ 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;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
for (const root of getTopTabsChromeRoots()) {
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(root, property);
}
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const roots = getTopTabsChromeRoots();
if (roots.length === 0) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
@@ -97,19 +105,21 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
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))');
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))');
}
topTabsChromeThemeVarsApplied = true;
}
-18
View File
@@ -4,7 +4,6 @@ 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…',
@@ -288,18 +287,6 @@ export const enAiMessages: Messages = {
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
'ai.mimo.title': 'MiMo Code',
'ai.mimo.description': "Xiaomi's MiMo Code CLI, an OpenCode fork. Install it and configure providers in its own config, then select it as an external coding agent.",
'ai.mimo.detecting': 'Detecting...',
'ai.mimo.detected': 'Detected',
'ai.mimo.notFound': 'Not found',
'ai.mimo.path': 'Path:',
'ai.mimo.notFoundHint': 'Could not find mimo in PATH. Install it or specify the executable path below.',
'ai.mimo.customPathPlaceholder': 'e.g. /usr/local/bin/mimo',
'ai.mimo.check': 'Check',
'ai.mimo.resetPath': 'Reset',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
@@ -613,11 +600,6 @@ 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.choosePanelPosition': 'Change position',
'terminal.layer.dockLeft': 'Left',
'terminal.layer.dockRight': 'Right',
'terminal.layer.dockBottom': 'Bottom',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
+5 -9
View File
@@ -359,6 +359,9 @@ 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',
@@ -538,7 +541,7 @@ Highlight the focused split pane:
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
'settings.appearance.appIcon.bright': 'Bright',
'settings.appearance.appIcon.dark': 'Dark',
@@ -614,8 +617,6 @@ Highlight the focused split pane:
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.smoothing': 'Font smoothing',
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
'settings.terminal.font.ligatures': 'Font ligatures',
'settings.terminal.font.ligatures.desc': 'Render ligatures such as != when the terminal font includes them',
'settings.terminal.font.linePadding': 'Line padding',
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
'settings.terminal.font.emulationType': 'Terminal emulation type',
@@ -623,8 +624,6 @@ Highlight the focused split pane:
'settings.terminal.cursor.style.block': 'Block',
'settings.terminal.cursor.style.bar': 'Bar',
'settings.terminal.cursor.style.underline': 'Underline',
'settings.terminal.cursor.barWidth': 'Bar cursor width',
'settings.terminal.cursor.barWidth.desc': 'Width of bar cursors used by applications such as vi (1-4 px)',
'settings.terminal.cursor.blink': 'Cursor blink',
'settings.terminal.cursor.highlightLine': 'Highlight current line',
'settings.terminal.cursor.highlightLine.desc':
@@ -709,10 +708,7 @@ Highlight the focused split pane:
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
-3
View File
@@ -27,9 +27,6 @@ export const enScriptsMessages = {
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
'scripts.actions.runOnAllTabs': 'Run on all tabs',
'scripts.actions.copyCommand': 'Copy command',
'scripts.actions.copyCommand.copied': 'Command copied',
'scripts.actions.copyCommand.failed': 'Could not copy command',
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
@@ -31,7 +31,6 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'Failed to load listening ports',
'systemManager.errors.loadServices': 'Failed to load systemd services',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
-1
View File
@@ -188,7 +188,6 @@ 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,6 +417,7 @@ 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',
-18
View File
@@ -4,7 +4,6 @@ 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…',
@@ -279,18 +278,6 @@ export const esAiMessages: Messages = {
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
'ai.mimo.description': 'CLI de MiMo Code de Xiaomi, un fork de OpenCode. Instálalo y configura los proveedores en su propia configuración, luego selecciónalo como agente de codificación externo.',
'ai.mimo.detecting': 'Detectando...',
'ai.mimo.detected': 'Detectado',
'ai.mimo.notFound': 'No encontrado',
'ai.mimo.path': 'Ruta:',
'ai.mimo.notFoundHint': 'No se pudo encontrar mimo en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.mimo.customPathPlaceholder': 'p. ej. /usr/local/bin/mimo',
'ai.mimo.check': 'Verificar',
'ai.mimo.resetPath': 'Restablecer',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
@@ -604,11 +591,6 @@ 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.choosePanelPosition': 'Cambiar posición',
'terminal.layer.dockLeft': 'Izquierda',
'terminal.layer.dockRight': 'Derecha',
'terminal.layer.dockBottom': 'Abajo',
'terminal.layer.closePanel': 'Cerrar panel',
'terminal.layer.closePane': 'Cerrar división',
'terminal.layer.resizeSplit': 'Redimensionar división',
+5 -9
View File
@@ -274,6 +274,9 @@ 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',
@@ -453,7 +456,7 @@ Resalta el panel dividido enfocado:
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
'settings.appearance.appIcon.bright': 'Brillante',
'settings.appearance.appIcon.dark': 'Oscuro',
@@ -529,8 +532,6 @@ Resalta el panel dividido enfocado:
'settings.terminal.font.weightBold.desc': 'Peso del texto en negrita (100-900)',
'settings.terminal.font.smoothing': 'Suavizado de fuente',
'settings.terminal.font.smoothing.desc': 'Usar el anti-aliasing nativo de fuentes de macOS',
'settings.terminal.font.ligatures': 'Ligaduras de fuente',
'settings.terminal.font.ligatures.desc': 'Mostrar ligaduras como != cuando la fuente del terminal las incluye',
'settings.terminal.font.linePadding': 'Espaciado de líneas',
'settings.terminal.font.linePadding.desc': 'Espacio adicional entre líneas (0-10)',
'settings.terminal.font.emulationType': 'Tipo de emulación de terminal',
@@ -538,8 +539,6 @@ Resalta el panel dividido enfocado:
'settings.terminal.cursor.style.block': 'Bloque',
'settings.terminal.cursor.style.bar': 'Barra',
'settings.terminal.cursor.style.underline': 'Subrayado',
'settings.terminal.cursor.barWidth': 'Ancho del cursor de barra',
'settings.terminal.cursor.barWidth.desc': 'Ancho de los cursores de barra utilizados por aplicaciones como vi (1-4 px)',
'settings.terminal.cursor.blink': 'Parpadeo del cursor',
'settings.terminal.cursor.highlightLine': 'Resaltar línea actual',
'settings.terminal.cursor.highlightLine.desc':
@@ -624,10 +623,7 @@ Resalta el panel dividido enfocado:
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
'Usa \\n para nueva línea, \\t para tabulación y \\\\ para barra invertida.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` borra el historial de desplazamiento',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Haz que `clear` también borre el buffer del historial de desplazamiento (predeterminado de POSIX). Desactívalo para mantener visible el historial después de `clear`.',
-3
View File
@@ -27,9 +27,6 @@ export const esScriptsMessages = {
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
'scripts.actions.copyCommand': 'Copiar comando',
'scripts.actions.copyCommand.copied': 'Comando copiado',
'scripts.actions.copyCommand.failed': 'No se pudo copiar el comando',
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
@@ -31,7 +31,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
'systemManager.overview.loading': 'Cargando resumen del sistema…',
-1
View File
@@ -188,7 +188,6 @@ 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.',
-6
View File
@@ -5,7 +5,6 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
@@ -560,11 +559,6 @@ export const ruAiMessages: Messages = {
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
'terminal.layer.choosePanelPosition': 'Изменить положение',
'terminal.layer.dockLeft': 'Слева',
'terminal.layer.dockRight': 'Справа',
'terminal.layer.dockBottom': 'Снизу',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.closePane': 'Закрыть область',
'terminal.layer.resizeSplit': 'Изменить размер области',
+5 -9
View File
@@ -343,6 +343,9 @@ 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': 'Показывать боковую панель хостов',
@@ -507,7 +510,7 @@ export const ruCoreMessages: Messages = {
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
'settings.appearance.appIcon.bright': 'Яркий',
'settings.appearance.appIcon.dark': 'Тёмный',
@@ -582,8 +585,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
'settings.terminal.font.ligatures': 'Лигатуры шрифта',
'settings.terminal.font.ligatures.desc': 'Показывать лигатуры вроде !=, если шрифт терминала их содержит',
'settings.terminal.font.linePadding': 'Межстрочный отступ',
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
@@ -591,8 +592,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.cursor.style.block': 'Блок',
'settings.terminal.cursor.style.bar': 'Полоса',
'settings.terminal.cursor.style.underline': 'Подчёркивание',
'settings.terminal.cursor.barWidth': 'Толщина курсора-полосы',
'settings.terminal.cursor.barWidth.desc': 'Ширина курсоров-полос, используемых приложениями вроде vi (1-4 пикс.)',
'settings.terminal.cursor.blink': 'Мигание курсора',
'settings.terminal.cursor.highlightLine': 'Подсветка текущей строки',
'settings.terminal.cursor.highlightLine.desc':
@@ -660,10 +659,7 @@ export const ruCoreMessages: Messages = {
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
-3
View File
@@ -27,9 +27,6 @@ export const ruScriptsMessages = {
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
'scripts.actions.copyCommand': 'Копировать команду',
'scripts.actions.copyCommand.copied': 'Команда скопирована',
'scripts.actions.copyCommand.failed': 'Не удалось скопировать команду',
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
@@ -31,7 +31,6 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
-1
View File
@@ -209,7 +209,6 @@ 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-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
@@ -7,11 +7,9 @@ import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhTW from "./zh-TW.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
{ name: "ru", messages: ru },
{ name: "es", messages: es },
];
@@ -85,21 +83,6 @@ test("localized settings include OSC desktop notification labels", () => {
}
});
test("localized settings include bar cursor width labels", () => {
const keys = [
"settings.terminal.cursor.barWidth",
"settings.terminal.cursor.barWidth.desc",
];
for (const locale of [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
]) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing bar cursor width labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
-18
View File
@@ -4,7 +4,6 @@ export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
@@ -288,18 +287,6 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安装后在它自己的配置里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
'ai.mimo.detecting': '检测中...',
'ai.mimo.detected': '已检测到',
'ai.mimo.notFound': '未找到',
'ai.mimo.path': '路径:',
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。请安装或在下方指定可执行文件路径。',
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
'ai.mimo.check': '检查',
'ai.mimo.resetPath': '重置',
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
@@ -613,11 +600,6 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.choosePanelPosition': '改变位置',
'terminal.layer.dockLeft': '左侧',
'terminal.layer.dockRight': '右侧',
'terminal.layer.dockBottom': '底部',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
+5 -1
View File
@@ -363,6 +363,9 @@ 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': '显示主机列表侧栏',
@@ -542,7 +545,7 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
@@ -1058,6 +1061,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
@@ -27,9 +27,6 @@ export const zhCNScriptsMessages = {
'scripts.actions.runParallel': '在所有标签页并行运行',
'scripts.actions.runSequential': '在所有标签页顺序运行',
'scripts.actions.runOnAllTabs': '在所有标签页运行',
'scripts.actions.copyCommand': '复制命令',
'scripts.actions.copyCommand.copied': '命令已复制',
'scripts.actions.copyCommand.failed': '复制命令失败',
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
'scripts.actions.skippedSensitiveSessions': '有 {count} 个标签处于密码/敏感输入,已跳过',
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
@@ -31,7 +31,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': '加载监听端口失败',
'systemManager.errors.loadServices': '加载 systemd 服务失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
+1 -8
View File
@@ -278,8 +278,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
'settings.terminal.font.smoothing': '字体平滑',
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
'settings.terminal.font.ligatures': '字体连字',
'settings.terminal.font.ligatures.desc': '支持连字的字体把 != 这类字符显示成一个符号',
'settings.terminal.font.linePadding': '行间距',
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
'settings.terminal.font.emulationType': '终端仿真类型',
@@ -287,8 +285,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.cursor.style.block': '块',
'settings.terminal.cursor.style.bar': '竖线',
'settings.terminal.cursor.style.underline': '下划线',
'settings.terminal.cursor.barWidth': '竖线光标粗细',
'settings.terminal.cursor.barWidth.desc': 'vi 等应用使用竖线光标时的宽度(1-4 像素)',
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.cursor.highlightLine': '高亮当前行',
'settings.terminal.cursor.highlightLine.desc': '为光标所在行加上淡色背景,方便定位当前输入位置',
@@ -348,10 +344,7 @@ export const zhCNTerminalMessages: Messages = {
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
'settings.terminal.behavior.shiftEnterForceText.desc':
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
-1
View File
@@ -579,7 +579,6 @@ 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': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
-18
View File
@@ -4,7 +4,6 @@ export const zhTWAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
@@ -279,18 +278,6 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重設',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安裝後在它自己的設定裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
'ai.mimo.detecting': '偵測中...',
'ai.mimo.detected': '已偵測到',
'ai.mimo.notFound': '未找到',
'ai.mimo.path': '路徑:',
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。請安裝或在下方指定執行檔路徑。',
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
'ai.mimo.check': '檢查',
'ai.mimo.resetPath': '重設',
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
@@ -603,11 +590,6 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.choosePanelPosition': '變更位置',
'terminal.layer.dockLeft': '左側',
'terminal.layer.dockRight': '右側',
'terminal.layer.dockBottom': '底部',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
+5 -1
View File
@@ -365,6 +365,9 @@ 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': '顯示主機列表側欄',
@@ -544,7 +547,7 @@ export const zhTWCoreMessages: Messages = {
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
@@ -1060,6 +1063,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
@@ -27,9 +27,6 @@ export const zhTWScriptsMessages = {
'scripts.actions.runParallel': '在所有分頁並行執行',
'scripts.actions.runSequential': '在所有分頁依序執行',
'scripts.actions.runOnAllTabs': '在所有分頁執行',
'scripts.actions.copyCommand': '複製指令',
'scripts.actions.copyCommand.copied': '指令已複製',
'scripts.actions.copyCommand.failed': '複製指令失敗',
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
@@ -31,7 +31,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
'systemManager.overview.empty': '暫無系統概覽資料。',
'systemManager.overview.loading': '正在載入系統概覽…',
+1 -8
View File
@@ -278,8 +278,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
'settings.terminal.font.smoothing': '字型平滑',
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
'settings.terminal.font.ligatures': '字型連字',
'settings.terminal.font.ligatures.desc': '支援連字的字型把 != 這類字元顯示成一個符號',
'settings.terminal.font.linePadding': '行間距',
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
'settings.terminal.font.emulationType': '終端模擬型別',
@@ -287,8 +285,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.cursor.style.block': '塊',
'settings.terminal.cursor.style.bar': '豎線',
'settings.terminal.cursor.style.underline': '下劃線',
'settings.terminal.cursor.barWidth': '豎線游標粗細',
'settings.terminal.cursor.barWidth.desc': 'vi 等應用程式使用豎線游標時的寬度(1-4 像素)',
'settings.terminal.cursor.blink': '游標閃爍',
'settings.terminal.cursor.highlightLine': '高亮目前行',
'settings.terminal.cursor.highlightLine.desc': '為游標所在行加上淡色背景,方便定位目前輸入位置',
@@ -348,10 +344,7 @@ export const zhTWTerminalMessages: Messages = {
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
'settings.terminal.behavior.shiftEnterForceText.desc':
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
-1
View File
@@ -579,7 +579,6 @@ 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,6 +23,7 @@ export type SettingsChromeSnapshot = {
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
tabBarPosition: 'top' | 'bottom';
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
@@ -57,6 +58,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
tabBarPosition: 'top',
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
@@ -84,6 +86,7 @@ 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,3 +1,4 @@
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';
@@ -44,6 +45,7 @@ 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,
@@ -102,6 +104,7 @@ 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>>;
@@ -150,6 +153,7 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
@@ -306,6 +310,9 @@ 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));
}
@@ -372,6 +379,7 @@ export function useSettingsIpcSync({
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
+12 -5
View File
@@ -1,3 +1,4 @@
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';
@@ -34,6 +35,7 @@ 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,
@@ -92,6 +94,7 @@ interface UseSettingsStorageSyncParams {
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
tabBarPosition: TabBarPosition;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
@@ -143,6 +146,7 @@ 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>>;
@@ -182,7 +186,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, 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,
@@ -191,7 +195,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, 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,
@@ -206,7 +210,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, 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,
});
@@ -216,7 +220,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, 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,
};
@@ -437,6 +441,9 @@ 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) {
@@ -594,7 +601,7 @@ export function useSettingsStorageSync({
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setShowRecentHostsState,
setShowSftpTabState,
setTabBarPositionState, setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
+1 -13
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
import { isMissingStatError } from "./errors";
test("isMissingStatError accepts only true path absence codes", () => {
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
@@ -64,15 +64,3 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
false,
);
});
test("unwrapSftpIpcError strips the Electron IPC wrapper", () => {
assert.equal(
unwrapSftpIpcError(
new Error("Error invoking remote method 'netcatty:sftp:delete': Error: Permission denied"),
),
"Permission denied",
);
assert.equal(unwrapSftpIpcError(new Error("Permission denied")), "Permission denied");
assert.equal(unwrapSftpIpcError("channel closed"), "channel closed");
});
-9
View File
@@ -18,13 +18,4 @@ export const isSessionError = (err: unknown): boolean => {
);
};
const SFTP_IPC_ERROR_PREFIX = /^Error invoking remote method 'netcatty:[^']+':\s*/i;
/** Drop Electron IPC wrapper so dialogs can show the server error. */
export function unwrapSftpIpcError(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err ?? "");
const unwrapped = raw.replace(SFTP_IPC_ERROR_PREFIX, "").replace(/^Error:\s*/, "").trim();
return unwrapped || raw;
}
export { isMissingStatError } from "../../../domain/sftpStatError";
@@ -0,0 +1,60 @@
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);
});
@@ -0,0 +1,56 @@
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]);
}
@@ -62,8 +62,6 @@ export interface SftpConnectOptions {
sourceSessionId?: string;
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
requireSourceSessionReuse?: boolean;
/** When false, never attach SFTP to a live terminal SSH transport. */
reuseTransport?: boolean;
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
connectRequestKey?: string;
/** Identifies the exact tab and connection created for a route-bound request. */
@@ -1076,7 +1074,6 @@ export const useSftpConnections = ({
openOptions: {
sessionId: sftpSessionId,
...credentials,
...(options?.reuseTransport === false ? { reuseTransport: false } : {}),
},
});
@@ -85,60 +85,6 @@ test("first-open follow uses a fresh terminal cwd instead of the cached home pat
await act(async () => renderer?.unmount());
});
test("first-open follow stays put when the terminal cwd is still an unresolved ~", async () => {
const navigatedPaths: string[] = [];
const connection = {
id: "conn-1",
hostId: "host-1",
currentPath: "/root",
homeDir: "/root",
status: "connected",
isLocal: false,
};
const sftpRef = {
current: {
leftPane: { connection, loading: false },
navigateTo: async (_side: "left", path: string) => {
navigatedPaths.push(path);
connection.currentPath = path;
return "reached" as const;
},
},
};
let renderer: ReactTestRenderer | null = null;
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeTerminalCwd: "~",
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
connectionLoading: false,
connectionPath: connection.currentPath,
connectionStatus: connection.status,
effectiveFollowTerminalCwd: true,
followTerminalCwdHost: host,
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: async () => "~",
onPendingFollowOverride: () => {},
sftpRef,
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, []);
assert.equal(connection.currentPath, "/root");
await act(async () => renderer?.unmount());
});
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
let resolveCwd: ((cwd: string) => void) | null = null;
const navigatedPaths: string[] = [];
@@ -16,9 +16,6 @@ import {
shouldReleaseInitialFollowSyncAttempt,
shouldResetInitialFollowTerminalCwdSync,
type SftpFollowTerminalCwdBlock,
resolveTerminalCwdForSftp,
isSftpFollowTargetPath,
fallbackFollowPathForUntrackedSession,
} from "../../../domain/sftpFollowTerminalCwd";
import type { Host } from "../../../types";
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
@@ -26,7 +23,6 @@ import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActio
type FollowConnection = {
id: string;
currentPath?: string | null;
homeDir?: string | null;
status: string;
isLocal?: boolean;
};
@@ -52,7 +48,6 @@ type GetTerminalCwd = (options?: {
type InitialFollowConnection = {
id: string;
currentPath?: string | null;
homeDir?: string | null;
status: string;
isLocal?: boolean;
};
@@ -90,21 +85,13 @@ const runInitialFollowTerminalCwdSync = async ({
return false;
}
const targetPath = resolveTerminalCwdForSftp(cwd, live.homeDir, live.currentPath);
if (!isSftpFollowTargetPath(targetPath)) {
// Prompt-only shortcuts such as `~` are not a real SFTP path yet. Stay on
// the connection's landing directory instead of listing `/~`.
setHandled({ connectionId: expectedConnectionId, terminalCwd: cwd });
return true;
}
setHandled({
connectionId: expectedConnectionId,
terminalCwd: staleTerminalCwd && staleTerminalCwd !== targetPath ? staleTerminalCwd : targetPath,
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
});
if (live.currentPath === targetPath) return true;
if (live.currentPath === cwd) return true;
const navigateResult = await navigate(targetPath, isEligible);
const navigateResult = await navigate(cwd, isEligible);
if (!isEligible()) return false;
const current = getConnection();
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
@@ -187,7 +174,6 @@ export function useSftpFollowTerminalCwd({
const ownerPanelOpenRef = useRef(ownerPanelOpen);
const hasActiveWorkRef = useRef(hasActiveWork);
const initialFollowReadyConnectionRef = useRef<string | null>(null);
const lastFollowOriginIdRef = useRef<string | null>(null);
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
@@ -271,16 +257,10 @@ export function useSftpFollowTerminalCwd({
});
if (!cwd) return;
if (!shouldApply()) return;
const connection = sftpRef.current.leftPane.connection;
const targetPath = resolveTerminalCwdForSftp(
cwd,
connection?.homeDir,
connection?.currentPath,
);
if (!isSftpFollowTargetPath(targetPath)) return;
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, { shouldApply });
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
if (navigateResult !== "reached" || !shouldApply()) return;
blockedFollowRef.current = null;
const connection = sftpRef.current.leftPane.connection;
if (connection?.id) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
}
@@ -295,23 +275,9 @@ export function useSftpFollowTerminalCwd({
const syncGeneration = followSyncGenerationRef.current;
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
const previousOriginId = lastFollowOriginIdRef.current;
const originChanged = Boolean(
previousOriginId
&& expectedSessionId
&& previousOriginId !== expectedSessionId,
);
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
if (!terminalCwd && originChanged) {
// Do not reuse an in-flight probe from the pane we just left.
terminalCwd = fallbackFollowPathForUntrackedSession({
originChanged,
homeDir: sftpRef.current.leftPane.connection?.homeDir,
currentPath: sftpRef.current.leftPane.connection?.currentPath,
});
} else if (!terminalCwd) {
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
@@ -369,36 +335,26 @@ export function useSftpFollowTerminalCwd({
liveTerminalCwd: activeTerminalCwdRef.current,
requireLiveTerminalCwd: usesLiveTerminalCwd,
});
if (!connection) return;
const targetPath = resolveTerminalCwdForSftp(
terminalCwd,
connection.homeDir,
connection.currentPath,
);
if (!isSftpFollowTargetPath(targetPath)) return;
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, {
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
shouldApply: shouldApplyCurrentFollowSync,
quiet: true,
});
if (!shouldApplyCurrentFollowSync()) return;
const currentConnection = sftpRef.current.leftPane.connection;
if (!currentConnection || currentConnection.id !== connection?.id) return;
if (navigateResult === "failed") {
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "superseded") {
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "reached") {
blockedFollowRef.current = null;
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
}
}, [
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork,
isVisible,
onGetTerminalCwd,
@@ -424,7 +380,6 @@ export function useSftpFollowTerminalCwd({
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
void syncFollowToTerminalCwd();
}, [
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
@@ -432,7 +387,6 @@ export function useSftpFollowTerminalCwd({
connectionIsLocal,
connectionStatus,
effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork,
isVisible,
syncFollowToTerminalCwd,
@@ -579,7 +533,7 @@ export function useSftpFollowTerminalCwd({
getConnection: () => sftpRef.current.leftPane.connection,
navigate: (cwd, shouldApply) => {
navigationStarted = true;
return sftpRef.current.navigateTo("left", cwd, { shouldApply, quiet: true });
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
},
setHandled: (value) => { handledFollowRef.current = value; },
setBlocked: (value) => { blockedFollowRef.current = value; },
@@ -5,7 +5,6 @@ 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",
@@ -17,22 +16,6 @@ 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",
@@ -0,0 +1,51 @@
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(); });
});
+17 -37
View File
@@ -2,7 +2,6 @@ import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { unwrapSftpIpcError } from "./errors";
import { SftpPane } from "./types";
import {
getFileName,
@@ -28,6 +27,13 @@ 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;
@@ -54,15 +60,14 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
/** Restore the previous listing without showing a pane error. */
quiet?: boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => 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;
@@ -182,6 +187,7 @@ 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);
@@ -284,7 +290,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -372,7 +378,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -383,7 +389,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -419,9 +425,8 @@ export const useSftpPaneActions = ({
files: previousFiles,
selectedFiles: previousSelection,
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
error: options?.quiet
? previousError
: unwrapSftpIpcError(err) || "Failed to list directory",
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
@@ -449,7 +454,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string }) => {
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -480,7 +485,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
} 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
@@ -1034,30 +1039,6 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const removeListedNames = useCallback((
side: "left" | "right",
parentPath: string,
names: string[],
) => {
if (names.length === 0) return;
const removeSet = new Set(names);
updateActiveTab(side, (prev) => {
if (!prev.connection || prev.connection.currentPath !== parentPath) return prev;
const nextSelection = new Set(prev.selectedFiles);
for (const name of names) nextSelection.delete(name);
return {
...prev,
files: prev.files.filter((file) => !removeSet.has(file.name)),
selectedFiles: nextSelection,
error: null,
};
});
const pane = getActivePane(side);
if (pane?.connection && !pane.connection.isLocal) {
clearCacheForConnection(pane.connection.id);
}
}, [clearCacheForConnection, getActivePane, updateActiveTab]);
return {
navigateTo,
refresh,
@@ -1075,7 +1056,6 @@ export const useSftpPaneActions = ({
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
removeListedNames,
renameFile,
renameFileAtPath,
moveEntriesToPath,
@@ -0,0 +1,12 @@
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); };
}
+1 -1
View File
@@ -1,6 +1,6 @@
type Listener = () => void;
export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
/**
* Live terminal CWD map + version token.
@@ -2,15 +2,10 @@ 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';
@@ -39,17 +34,3 @@ 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,37 +41,3 @@ 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));
}
+3 -8
View File
@@ -174,13 +174,10 @@ export function useAgentDiscovery(
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
: match.command === 'opencode'
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
: match.command === 'mimo'
? { ...(ea.env ?? {}), MIMOCODE_BIN: matchPath }
: ea.env;
: ea.env;
const envChanged =
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath)
|| (match.command === 'mimo' && ea.env?.MIMOCODE_BIN !== matchPath);
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
const versionChanged = Boolean(match.version) && ea.cliVersion !== match.version;
if (currentArgs !== newArgs || backendChanged || envChanged || versionChanged) {
changed = true;
@@ -223,9 +220,7 @@ export function useAgentDiscovery(
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
: agent.command === 'opencode'
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
: agent.command === 'mimo'
? { env: { MIMOCODE_BIN: agent.binPath || agent.path || '' } }
: {}),
: {}),
};
},
[],
@@ -29,9 +29,6 @@ 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();
@@ -41,11 +38,9 @@ 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);
+1 -7
View File
@@ -29,11 +29,5 @@ export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVi
});
return () => { cancelled = true; };
}, [isVisible, 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,
};
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
}
@@ -201,96 +201,3 @@ 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,
});
}
});
+12 -6
View File
@@ -55,20 +55,26 @@ export const useManagedSourceSync = ({
);
const readExistingFileContent = useCallback(
async (filePath: string): Promise<string> => {
async (filePath: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) {
throw new Error("readLocalFile not available");
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;
}
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
},
[],
);
const mergeWithExistingContent = useCallback(
(
existingContent: string,
existingContent: string | null,
managedHosts: Host[],
allHosts: Host[],
): string => {
@@ -76,7 +82,7 @@ export const useManagedSourceSync = ({
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
if (!existingContent) {
// The existing file is empty; wrap the managed content.
// No existing file, just wrap the managed content
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
}
-16
View File
@@ -66,8 +66,6 @@ import {
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
export function addWorkspaceIfMissing(
workspaces: Workspace[],
@@ -269,20 +267,6 @@ export const useSessionState = ({
}
}, [initialRestoreState.activeTabId]);
// Keep open sessions' connection label in sync with the vault. Sessions
// snapshot `hostLabel` at connect time, so renaming a host would otherwise
// leave its already-open tabs (and merged-workspace titles derived from
// those labels) showing the old name until the session is closed. The
// generated-title sync effect below re-runs on this session change and
// refreshes synthesized workspace titles too.
const vaultHosts = useVaultSnapshotField('hosts');
useEffect(() => {
if (vaultHosts.length === 0) return;
// The helper returns the same array reference when nothing changed, so
// React bails out of the state update.
setSessions(prev => syncSessionHostLabels(prev, vaultHosts));
}, [vaultHosts]);
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
// "02" onto "01") synchronized with the workspace's current membership and
// each pane's connection label. `autoTitle: false` makes
@@ -1,103 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { Host } from "../../domain/models";
import { EMPTY_VAULT_SNAPSHOT, publishVaultSnapshot } from "./vaultSnapshotStore";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
const testHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "web-01",
hostname: "10.0.0.1",
port: 22,
username: "root",
os: "linux",
tags: [],
...overrides,
} as Host);
test("rewriting the host label updates already-open session tabs on that host", async (t) => {
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
t.after(() => publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT));
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let sessionId = "";
await act(async () => {
sessionId = current().connectToHost(testHost());
});
const sessionOf = () => current().sessions.find((s) => s.id === sessionId);
assert.ok(sessionOf());
assert.equal(sessionOf()?.hostLabel, "web-01");
// The host is renamed in the vault while the session stays open.
await act(async () => {
publishVaultSnapshot({
...EMPTY_VAULT_SNAPSHOT,
hosts: [testHost({ label: "web-01-renamed" })],
});
});
assert.equal(sessionOf()?.hostLabel, "web-01-renamed");
// Renaming the pane afterwards hands the title to the user rename.
await act(async () => {
current().submitSessionRename(sessionId, "my pane");
});
const renamed = sessionOf();
assert.ok(renamed);
assert.equal(renamed!.customName, "my pane");
// A later vault rename must not clobber the user-authored pane name.
await act(async () => {
publishVaultSnapshot({
...EMPTY_VAULT_SNAPSHOT,
hosts: [testHost({ label: "web-01-again" })],
});
});
const after = sessionOf();
assert.ok(after);
assert.equal(after!.customName, "my pane");
await act(async () => {
renderer?.unmount();
});
});
+22 -3
View File
@@ -1,3 +1,4 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -78,6 +79,7 @@ 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,
@@ -415,6 +417,9 @@ 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;
@@ -1022,6 +1027,7 @@ 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);
@@ -1216,6 +1222,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
@@ -1274,7 +1281,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, 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,
@@ -1284,7 +1291,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, 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,
@@ -1414,6 +1421,14 @@ 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);
@@ -1900,6 +1915,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
uiLanguage,
windowOpacity,
tabBarPosition,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
@@ -1928,6 +1944,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
tabBarPosition,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
@@ -2102,7 +2119,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
tabBarPosition,
showSftpTab,
setTabBarPosition,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
@@ -2182,7 +2201,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, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
-4
View File
@@ -469,7 +469,6 @@ export const useSftpState = (
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
removeListedNames,
renameFile,
renameFileAtPath,
moveEntriesToPath,
@@ -782,7 +781,6 @@ export const useSftpState = (
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
removeListedNames,
renameFile,
renameFileAtPath,
moveEntriesToPath,
@@ -859,8 +857,6 @@ export const useSftpState = (
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
removeListedNames: (...args: Parameters<typeof removeListedNames>) =>
methodsRef.current.removeListedNames(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
-3
View File
@@ -15,9 +15,6 @@ function delay(ms: number): Promise<void> {
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const lower = message.toLowerCase();
if (lower.includes('extra exec channels')) {
return t('systemManager.unsupportedRemote');
}
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
return t('systemManager.errors.sshChannelUnavailable');
}
+1 -1
View File
@@ -521,7 +521,7 @@ export const useTerminalBackend = () => {
const getSessionPwd = useCallback(async (
sessionId: string,
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean; timeoutMs?: number },
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
applyAgentNotesUpdate,
haveSameVaultAgentSnapshot,
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
@@ -28,33 +27,6 @@ describe('haveSameVaultAgentSnapshot', () => {
});
});
describe('applyAgentNotesUpdate', () => {
it('rolls back an unsaved agent note before a later successful write', () => {
const oldNotes = [{ id: 'old', title: 'Old', content: 'old', createdAt: 1, updatedAt: 1 }];
const noteA = { id: 'a', title: 'A', content: 'a', createdAt: 2, updatedAt: 2 };
const noteB = { id: 'b', title: 'B', content: 'b', createdAt: 3, updatedAt: 3 };
let inMemory = oldNotes;
let persisted = oldNotes;
let failNext = true;
const updateNotes = (notes: typeof oldNotes) => {
inMemory = notes;
if (failNext) {
failNext = false;
return false;
}
persisted = notes;
return true;
};
assert.equal(applyAgentNotesUpdate([...oldNotes, noteA], oldNotes, updateNotes), false);
assert.deepEqual(inMemory, oldNotes);
assert.deepEqual(persisted, oldNotes);
assert.equal(applyAgentNotesUpdate([...inMemory, noteB], inMemory, updateNotes), true);
assert.deepEqual(persisted.map((note) => note.id), ['old', 'b']);
});
});
describe('resolveVaultAgentEffectiveHost', () => {
it('uses the latest snapshotted group defaults', () => {
const host: Host = {
+2 -19
View File
@@ -89,20 +89,6 @@ export function resolveVaultAgentNotes(
: (getNotesSnapshot().notes as VaultNote[]);
}
export function applyAgentNotesUpdate(
notes: VaultNote[],
previousNotes: VaultNote[],
updateNotes: (notes: VaultNote[]) => boolean | void,
): boolean | void {
const saved = updateNotes(notes);
if (saved === false) {
// Editor autosave keeps failed writes in memory so a draft is not lost.
// Agent writes must roll that draft back before another approved write.
updateNotes(previousNotes);
}
return saved;
}
export const haveSameVaultAgentSnapshot = (
left: VaultAgentSnapshot,
right: VaultAgentSnapshot,
@@ -159,7 +145,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
?? ((notes: VaultNote[]) => {
console.warn('[useVaultAgentBridge] updateNotes unavailable');
void notes;
return false;
});
return handleVaultAgentOp(op, params, {
getHosts: () => vaultSnapshotRef.current.hosts,
@@ -254,10 +239,8 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
updateKeys: current.updateKeys,
}),
updateNotes: (notes) => {
const previousNotes = vaultSnapshotRef.current.notes;
const saved = applyAgentNotesUpdate(notes, previousNotes, applyUpdateNotes);
vaultSnapshotRef.current.notes = saved === false ? previousNotes : notes;
return saved;
vaultSnapshotRef.current.notes = notes;
applyUpdateNotes(notes);
},
updateSnippets: (snippetUpdate) => {
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
-1
View File
@@ -186,7 +186,6 @@ 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,51 +48,6 @@ 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}`);
+15 -159
View File
@@ -4,8 +4,7 @@ import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
import { clampTerminalFontSizeValue, parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
import { parseCustomAccentRecord } from "./state/customAccentSync.ts";
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
import commandBlocklistTable from "../lib/commandBlocklist.json";
type LocalStorageMock = {
@@ -499,134 +498,6 @@ test("applySyncPayload writes a newer terminal font size that old versions can r
assert.equal(record.origin, "sync-payload");
});
test("applying identical versioned settings preserves their records, then propagates real changes", async () => {
const scenarios: Array<{
name: string;
storageKey: string;
field: keyof NonNullable<SyncPayload["settings"]>;
initialValue: string | number | Record<string, unknown>;
changedValue: string | number | Record<string, unknown>;
}> = [
{
name: "accent",
storageKey: storageKeys.STORAGE_KEY_COLOR,
field: "customAccent",
initialValue: parseCustomAccentRecord(null).color,
changedValue: "0 84% 60%",
},
{
name: "terminal font size",
storageKey: storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
field: "terminalFontSize",
initialValue: parseTerminalFontSizeRecord(null).fontSize,
changedValue: 19,
},
{
name: "custom key bindings",
storageKey: storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
field: "customKeyBindings",
initialValue: {},
changedValue: { open: { mac: "Cmd+K" } },
},
];
for (const scenario of scenarios) {
localStorage.clear();
const writes: string[] = [];
const originalSetItem = localStorage.setItem;
localStorage.setItem = (key, value) => {
writes.push(key);
originalSetItem(key, value);
};
const applySetting = (value: typeof scenario.initialValue) => applySyncPayload({
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
settings: { [scenario.field]: value },
} as SyncPayload, { importVaultData: () => {} });
try {
await applySetting(scenario.initialValue);
const firstRecord = localStorage.getItem(scenario.storageKey);
assert.ok(firstRecord, `${scenario.name}: an explicit first value must be saved`);
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1);
writes.length = 0;
await applySetting(scenario.initialValue);
assert.equal(localStorage.getItem(scenario.storageKey), firstRecord);
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 0,
`${scenario.name}: unchanged value must not write or trigger a storage change`);
writes.length = 0;
await applySetting(scenario.changedValue);
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1,
`${scenario.name}: a real change must be saved`);
assert.notEqual(localStorage.getItem(scenario.storageKey), firstRecord);
assert.deepEqual(buildSyncPayload(vault([])).settings?.[scenario.field], scenario.changedValue);
} finally {
localStorage.setItem = originalSetItem;
}
}
});
test("sync replaces malformed setting records and does not repeat normalized writes", async () => {
const versionedKeys = [
storageKeys.STORAGE_KEY_COLOR,
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
];
for (const key of versionedKeys) localStorage.setItem(key, "bad");
const writes: string[] = [];
const originalSetItem = localStorage.setItem;
localStorage.setItem = (key, value) => {
writes.push(key);
originalSetItem(key, value);
};
const settings = {
customAccent: parseCustomAccentRecord(null).color,
terminalFontSize: parseTerminalFontSizeRecord(null).fontSize,
customKeyBindings: {},
};
const applySettings = (nextSettings: NonNullable<SyncPayload["settings"]>) => applySyncPayload({
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
settings: nextSettings,
}, { importVaultData: () => {} });
try {
await applySettings(settings);
for (const key of versionedKeys) {
assert.notEqual(localStorage.getItem(key), "bad", `${key}: malformed record must be repaired`);
assert.equal(writes.filter((written) => written === key).length, 1);
}
writes.length = 0;
await applySettings(settings);
for (const key of versionedKeys) {
assert.equal(writes.filter((written) => written === key).length, 0,
`${key}: repaired record must not be written again`);
}
const oversizedFontSize = 100;
writes.length = 0;
await applySettings({ terminalFontSize: oversizedFontSize });
assert.equal(parseTerminalFontSizeRecord(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE)).fontSize,
clampTerminalFontSizeValue(oversizedFontSize));
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1);
writes.length = 0;
await applySettings({ terminalFontSize: oversizedFontSize });
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 0,
"a repeated out-of-range incoming size must not bump the stored version again");
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE, "999");
writes.length = 0;
await applySettings({ terminalFontSize: oversizedFontSize });
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1,
"an out-of-range legacy record must be replaced with the normalized value");
assert.notEqual(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE), "999");
} finally {
localStorage.setItem = originalSetItem;
}
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
@@ -1714,26 +1585,6 @@ test("applySyncPayload restores the terminal host information bar preference", a
assert.equal(parsed.scrollback, 5000);
});
test("bar cursor width survives terminal settings sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ cursorBarWidth: 3 }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.cursorBarWidth, 3);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ cursorBarWidth: 1 }),
);
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.cursorBarWidth, 3);
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
@@ -2150,17 +2001,22 @@ test("missing local shell settings and invalid tabs preserve existing settings",
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
});
test("old bottom tab preference is ignored by settings export and import", async () => {
test("tab bar position survives settings export and import, with a safe fallback", async () => {
localStorage.clear();
const legacyKey = "netcatty_tab_bar_position_v1";
localStorage.setItem(legacyKey, "bottom");
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
const payload = buildSyncPayload(vault([]));
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);
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");
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
+39 -56
View File
@@ -1,3 +1,4 @@
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
@@ -51,7 +52,6 @@ import {
} from './state/commandBlocklistSettings';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
clampTerminalFontSizeValue,
nextTerminalFontSizeSyncVersion,
parseTerminalFontSizeRecord,
serializeTerminalFontSizeRecord,
@@ -60,7 +60,6 @@ import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -97,6 +96,7 @@ 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,
@@ -259,9 +259,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'cursorBarWidth', 'highlightCursorLine', 'minimumContrastRatio',
'linePadding', 'cursorShape', 'cursorBlink', 'highlightCursorLine', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
'shiftEnterForceText',
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
@@ -324,6 +323,7 @@ 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,6 +557,8 @@ 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);
@@ -692,25 +694,15 @@ async function applySyncableSettings(
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
if (settings.customAccent != null) {
const storedColor = localStorageAdapter.readString(STORAGE_KEY_COLOR);
const existing = parseCustomAccentRecord(storedColor);
const incomingColor = parseCustomAccentRecord(settings.customAccent).color;
// A missing or malformed record parses to the fallback color, but still
// needs to be replaced by an explicit incoming value.
const storedColorIsValid = storedColor !== null && (
isValidHslToken(storedColor.trim())
|| storedColor === serializeCustomAccentRecord(existing)
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord({
color: parseCustomAccentRecord(settings.customAccent).color,
// Bump so peer windows' version gates accept the synced value.
version: Math.max(existing.version, 0) + 1,
}),
);
if (!storedColorIsValid || incomingColor !== existing.color) {
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord({
color: incomingColor,
// Bump so peer windows' version gates accept the synced value.
version: Math.max(existing.version, 0) + 1,
}),
);
}
}
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
@@ -732,26 +724,18 @@ async function applySyncableSettings(
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) {
const storedFontSize = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
const existing = parseTerminalFontSizeRecord(storedFontSize);
const incomingFontSize = clampTerminalFontSizeValue(settings.terminalFontSize);
// A missing or malformed record parses to the default font size. A
// supported legacy number remains valid without forcing migration.
const storedFontSizeIsValid = storedFontSize !== null && (
storedFontSize.trim() === String(existing.fontSize)
|| storedFontSize === serializeTerminalFontSizeRecord(existing)
const existing = parseTerminalFontSizeRecord(
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
serializeTerminalFontSizeRecord({
fontSize: settings.terminalFontSize,
// Bump so peer windows' version gates accept the synced value.
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
origin: 'sync-payload',
}),
);
if (!storedFontSizeIsValid || incomingFontSize !== existing.fontSize) {
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
serializeTerminalFontSizeRecord({
fontSize: incomingFontSize,
// Bump so peer windows' version gates accept the synced value.
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
origin: 'sync-payload',
}),
);
}
}
if (settings.terminalSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
@@ -805,21 +789,17 @@ async function applySyncableSettings(
// Keyboard
if (settings.customKeyBindings != null) {
const storedBindings = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
const previous = parseCustomKeyBindingsStorageRecord(storedBindings);
// Keep an explicit empty binding set when this device has no record.
const incomingBindings = JSON.stringify(settings.customKeyBindings);
const existingBindings = JSON.stringify(previous?.bindings ?? []);
if (previous === null || incomingBindings !== existingBindings) {
localStorageAdapter.writeString(
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
serializeCustomKeyBindingsStorageRecord({
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
bindings: settings.customKeyBindings,
}),
);
}
const previous = parseCustomKeyBindingsStorageRecord(
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
);
localStorageAdapter.writeString(
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
serializeCustomKeyBindingsStorageRecord({
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
bindings: settings.customKeyBindings,
}),
);
}
// Editor
@@ -850,6 +830,9 @@ 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,8 +67,6 @@ 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[];
@@ -140,7 +138,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelDisplayName,
modelCatalogWarning,
agentModelPresets,
allowCustomModelEntry = true,
selectedAgentModel,
handleAgentModelSelect,
cattyConfiguredProviders,
@@ -357,7 +354,6 @@ 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}
+27 -103
View File
@@ -22,14 +22,6 @@ 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 {
@@ -319,8 +311,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
@@ -851,34 +843,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);
const {
model: codexConfigModel,
loadModel: loadCodexConfigModel,
isPending: isCodexConfigModelPending,
} = useCodexConfigModel(
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = 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);
@@ -903,21 +874,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
externalAgentsRef.current = externalAgents;
const applySdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
agentId: string,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
const agentId = target.agentId;
const catalogPresets = mergeFallbackThinkingLevels(
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
const runtimePresets = 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) => {
@@ -927,10 +892,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
} else {
setRuntimeAgentModelPresets((prev) => (
prev[agentId]?.cacheKey === target.cacheKey
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
? prev
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
: { ...prev, [agentId]: runtimePresets }
));
}
@@ -950,7 +914,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
setAgentModel(agentId, catalog.currentModelId);
}
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
}, [setAgentModel]);
const loadSdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
@@ -975,15 +939,10 @@ 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) {
next[target.agentId] = {
cacheKey: target.cacheKey,
message: t('ai.chat.modelCatalogWarning'),
};
console.warn('[AIChatSidePanel] SDK model catalog unavailable:', result.warning);
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);
} else {
delete next[target.agentId];
}
@@ -996,20 +955,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
},
{ force: options.force },
).catch((err) => {
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
return {
if (target.codexRuntime === 'app-server') {
setRuntimeModelWarnings((current) => ({
...current,
[target.agentId]: { cacheKey: target.cacheKey, message: t('ai.chat.modelCatalogWarning') },
};
});
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
}));
}
if (options.logErrors !== false) {
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
return null;
});
}, [t, buildExternalAgentRuntimeModelTarget]);
}, [t]);
useEffect(() => {
if (!isVisible) return;
@@ -1021,7 +978,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cached = sdkRuntimeModelCache.read(target.cacheKey);
if (cached) {
applySdkRuntimeModelCatalog(target, cached);
applySdkRuntimeModelCatalog(target.agentId, cached);
}
// Respect renderer TTL / in-flight coalescing for all SDK agents including
@@ -1033,7 +990,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cancelIdle = scheduleWhenAiComposerIdle(() => {
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
if (cancelled || !catalog) return;
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
});
});
@@ -1053,50 +1010,27 @@ 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 target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
const runtimePresets = runtimeEntry && target && runtimeEntry.cacheKey === target.cacheKey
? runtimeEntry.models
: undefined;
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) {
return appendComposerCustomModelPresets(
runtimePresets,
collectCustomModelIds(currentAgentId, runtimePresets),
);
}
if (runtimePresets) return 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);
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),
);
return filterAgentModelPresetsForCliVersion(presets, cliVersion);
}, [
currentAgentConfig,
currentAgentId,
runtimeAgentModelPresets,
buildExternalAgentRuntimeModelTarget,
hasCodexCustomConfig,
codexConfigModel,
discoveredAgents,
collectCustomModelIds,
]);
const selectedAgentModel = useMemo(() => {
@@ -1279,18 +1213,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
if (catalog) {
applySdkRuntimeModelCatalog(runtimeTarget, catalog, { adoptCurrentModel: true });
const catalogPresets = mergeFallbackThinkingLevels(
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
const runtimePresets = 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
@@ -1473,7 +1403,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog, collectCustomModelIds,
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog,
]);
const handleCompact = useCallback(async () => {
@@ -1790,14 +1720,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
? runtimeModelWarnings[currentAgentId].message
: undefined}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
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}
+1 -44
View File
@@ -89,11 +89,6 @@ 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(
@@ -142,7 +137,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')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
});
@@ -161,44 +156,6 @@ 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('MiMo model cache reloads after its config directory changes', async () => {
const agent = { id: 'discovered_mimo', sdkBackend: 'mimo', command: '/bin/mimo' };
const firstKey = buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_HOME: '/profiles/a' } });
const secondKey = buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_HOME: '/profiles/b' } });
const cache = createSdkRuntimeModelCache();
let loads = 0;
const load = async () => ({ currentModelId: String(++loads), models: [] });
assert.deepEqual(await cache.refresh(firstKey, load), { currentModelId: '1', models: [] });
assert.deepEqual(await cache.refresh(secondKey, load), { currentModelId: '2', models: [] });
assert.equal(loads, 2);
assert.notEqual(
buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_CONFIG: '/profiles/a/mimocode.json' } }),
buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_CONFIG: '/profiles/b/mimocode.json' } }),
);
});
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', () => {
+19 -35
View File
@@ -4,19 +4,9 @@ import {
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
} from '../infrastructure/ai/composerPicker';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
export {
canonicalizeEffortEncodedModelId,
modelPresetMatchesId,
modelPresetsContainId,
};
export { canonicalizeEffortEncodedModelId };
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
@@ -45,23 +35,10 @@ 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',
'OPENCODE_CONFIG_CONTENT',
'MIMOCODE_HOME',
'MIMOCODE_BIN',
'MIMOCODE_BIN_PATH',
'MIMOCODE_CONFIG',
'MIMOCODE_CONFIG_DIR',
'MIMOCODE_MIMO_ONLY',
'XDG_DATA_HOME',
'XDG_CACHE_HOME',
'CLAUDE_CODE_EXECUTABLE',
'CODEBUDDY_CODE_PATH',
'CURSOR_API_KEY',
@@ -126,9 +103,7 @@ export function buildSdkRuntimeModelCacheKey(agent: {
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
// 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');
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
@@ -215,7 +190,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 !== undefined) return preset;
if (preset.thinkingLevels?.length) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
@@ -250,6 +225,19 @@ 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[],
@@ -265,17 +253,13 @@ export function normalizeStoredAgentModelSelection(
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
// 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'
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|| sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
|| sdkBackend === 'codebuddy'
|| sdkBackend === 'opencode'
|| sdkBackend === 'grok'
|| sdkBackend === 'mimo';
|| sdkBackend === 'grok';
}
export function shouldAdoptSdkCurrentModel(
+13 -10
View File
@@ -17,7 +17,6 @@ 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";
@@ -423,16 +422,16 @@ echo $3 >> "$FILE"`);
}
// Detect key type from private key content
const detected = detectSshKeyType(
draftKey.privateKey,
draftKey.publicKey,
);
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 newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: detected.type,
keySize: detected.keySize,
type: (draftKey.type as KeyType) || detectedType,
privateKey: draftKey.privateKey.trim(),
publicKey: draftKey.publicKey?.trim() || undefined,
certificate: draftKey.certificate?.trim() || undefined,
@@ -542,7 +541,12 @@ echo $3 >> "$FILE"`);
const content = e.target?.result as string;
if (content) {
// Try to detect key type from content
const detected = detectSshKeyType(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";
// Extract label from filename (remove extension)
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
@@ -551,8 +555,7 @@ echo $3 >> "$FILE"`);
...prev,
privateKey: content,
label: prev.label || label,
type: detected.type,
keySize: detected.keySize,
type: detectedType,
}));
}
};
-13
View File
@@ -53,19 +53,6 @@ test("scripts side panel offers run-on-all-tabs for every snippet, not only auto
assert.match(source, /openPackageDialog/);
});
test("scripts side panel snippet context menu offers copy command without running", () => {
// Copy must sit between the run actions and Edit, write snippet.command to
// the clipboard, and confirm with a toast — it never executes the command.
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.snippet\)\}/);
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.row\.snippet\)\}/);
assert.match(source, /scripts\.actions\.copyCommand'/);
const menu = source.match(/\{onRunParallel \? \([\s\S]*?<\/ContextMenu>/);
assert.ok(menu, "snippet row context menu should exist");
assert.match(menu[0], /onCopyCommand[\s\S]*?onClick=\{onEdit\}/);
assert.match(menu[0], /<Copy className="[\s\S]*?\{copyCommandLabel\}/);
assert.match(source, /handleCopySnippetCommand[\s\S]*?snippet\.command[\s\S]*?toast\.success/);
});
test("scripts side panel exposes create actions as inline toolbar icons", () => {
assert.match(source, /snippets\.action\.newSnippet/);
assert.match(source, /snippets\.action\.newPackage/);
-29
View File
@@ -10,7 +10,6 @@
import {
CheckSquare,
ChevronRight,
Copy,
Edit2,
Expand,
FolderPlus,
@@ -55,7 +54,6 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { isNonPrimaryPointer, primaryOnlyDragHandlers } from './ui/primaryOnlyDrag';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { toast } from './ui/toast';
const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
@@ -891,20 +889,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
);
}, []);
const handleCopySnippetCommand = useCallback((snippet: Snippet) => {
if (!snippet.command) return;
navigator.clipboard.writeText(snippet.command).then(
() => {
toast.success(t('scripts.actions.copyCommand.copied'), {
title: snippet.label,
});
},
() => {
toast.error(t('scripts.actions.copyCommand.failed'));
},
);
}, [t]);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
@@ -1145,8 +1129,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
onCopyCommand={() => handleCopySnippetCommand(item.snippet)}
copyCommandLabel={t('scripts.actions.copyCommand')}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.snippet, 'parallel')
: undefined}
@@ -1200,8 +1182,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
onCopyCommand={() => handleCopySnippetCommand(item.row.snippet)}
copyCommandLabel={t('scripts.actions.copyCommand')}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(item.row.snippet, 'parallel')
: undefined}
@@ -1525,8 +1505,6 @@ interface SnippetRowProps {
onDelete: () => void;
onRunParallel?: () => void;
onRunSequential?: () => void;
onCopyCommand?: () => void;
copyCommandLabel?: string;
runParallelLabel?: string;
runSequentialLabel?: string;
editLabel: string;
@@ -1549,8 +1527,6 @@ const SnippetRow = memo<SnippetRowProps>(({
onDelete,
onRunParallel,
onRunSequential,
onCopyCommand,
copyCommandLabel,
runParallelLabel,
runSequentialLabel,
editLabel,
@@ -1628,11 +1604,6 @@ const SnippetRow = memo<SnippetRowProps>(({
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
{onCopyCommand ? (
<ContextMenuItem onClick={onCopyCommand}>
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
+2
View File
@@ -557,6 +557,8 @@ 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}
+34 -93
View File
@@ -1,3 +1,4 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -76,7 +77,6 @@ import {
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.cjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
@@ -96,8 +96,6 @@ import {
shouldCancelSettledPendingSftpRebindWithoutTarget,
shouldDeferPendingSftpUploadForOriginFocus,
shouldDeferSftpSidePanelAutoConnectForSession,
isSplitFocusSftpStillConnected,
shouldKeepConnectedSftpOnSplitFocus,
shouldRebindSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
shouldStartPendingSftpUploadRebind,
@@ -573,41 +571,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const lastBrowsedPathByConnectionKeyRef = useRef<Map<string, string>>(new Map());
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
const [sftpUiReady, setSftpUiReady] = useState(false);
const terminalBackend = useTerminalBackend();
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
sessionId: string;
singleChannel: boolean;
} | null>(null);
const activeSessionStatusForBanner = activeSessionId
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
: null;
useEffect(() => {
if (!activeSessionId || activeSessionStatusForBanner !== "connected") {
setSftpBannerDecision(null);
return;
}
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
if (!readRemoteInfo) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
return;
}
let cancelled = false;
void readRemoteInfo(activeSessionId).then((info) => {
if (cancelled) return;
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
});
}).catch(() => {
if (!cancelled) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
}
});
return () => {
cancelled = true;
};
}, [activeSessionId, activeSessionStatusForBanner, terminalBackend]);
useEffect(() => {
pruneSftpSidePanelState(
@@ -731,16 +694,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
previousStatus: lastSourceSessionStatusRef.current,
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
});
// The peer banner is known only after the terminal handshake. Wait for it
// before choosing a shared terminal channel, so a one-channel bastion
// dials its file login directly.
if (
activeSessionId
&& activeSessionStatus === "connected"
&& sftpBannerDecision?.sessionId !== activeSessionId
) {
return;
}
const hasBackendSession = (connectionId: string) => !!s.getSftpIdForConnection(connectionId);
const activeTab = s.leftTabs.tabs.find((tab) => tab.id === s.leftTabs.activeTabId) ?? null;
@@ -756,33 +709,16 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Rebind when the focused terminal SSH session changes: saved host keys can
// lag live session endpoints (edited host / unsaved user). Still keep the
// browsed path sticky via remembered initialPath below.
// Single-channel SFTP does not borrow that terminal, so A -> B -> A must
// keep the login that is already up for this endpoint.
const backendSessionUp = activeConnectionId ? hasBackendSession(activeConnectionId) : false;
const endpointAlreadyConnected = shouldSkipSftpSidePanelAutoConnect(
connectionKey,
connectedKeyRef.current,
activeTab,
backendSessionUp,
activeTabConnectionKey,
);
const splitEndpointStillUp = isSplitFocusSftpStillConnected(
connectionKey,
connectedKeyRef.current,
activeTab,
backendSessionUp,
activeTabConnectionKey,
);
const keepSplitSftp = shouldKeepConnectedSftpOnSplitFocus({
sessionChanged,
previousSessionId: lastSourceSessionIdRef.current,
nextSessionId: activeSessionId,
endpointAlreadyConnected: splitEndpointStillUp,
});
if (
!pendingRequiresForcedRebind
&& (endpointAlreadyConnected || splitEndpointStillUp)
&& (!sessionChanged || keepSplitSftp)
!sessionChanged
&& !pendingRequiresForcedRebind
&& shouldSkipSftpSidePanelAutoConnect(
connectionKey,
connectedKeyRef.current,
activeTab,
activeConnectionId ? hasBackendSession(activeConnectionId) : false,
activeTabConnectionKey,
)
) {
if (activeSessionId) {
lastSourceSessionIdRef.current = activeSessionId;
@@ -934,20 +870,13 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel;
const reuseTerminalTransport = !dedicatedSftpLogin && (
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
);
const connect = (
connectRequestKey?: string,
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void,
) => s.connect("left", activeHost, {
sourceSessionId: reuseTerminalTransport
? (pendingStrictSourceSessionId ?? (activeSessionId ?? undefined))
: undefined,
requireSourceSessionReuse: reuseTerminalTransport && Boolean(pendingStrictSourceSessionId),
...(reuseTerminalTransport ? {} : { reuseTransport: false }),
sourceSessionId: pendingStrictSourceSessionId
?? (activeSessionStatus === "connected" ? (activeSessionId ?? undefined) : undefined),
requireSourceSessionReuse: Boolean(pendingStrictSourceSessionId),
...(connectRequestKey ? { connectRequestKey } : undefined),
onConnectionCreated,
...(initialPath ? { initialPath } : undefined),
@@ -979,7 +908,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
pendingUploadRebindSettledRequestId,
pendingUploadRebindStartedIdRef,
sessions,
sftpBannerDecision,
startPendingUploadRebind,
]);
@@ -1381,10 +1309,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
activeSessionId={activeSessionId}
bannerRequiresSingleChannel={
sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel
}
focusedSessionId={focusedSessionId}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
@@ -1433,7 +1357,6 @@ type SftpSidePanelInteractiveBodyProps = {
sftpDefaultViewMode: "list" | "tree";
activeHost: Host | null;
activeSessionId?: string | null;
bannerRequiresSingleChannel?: boolean;
focusedSessionId?: string | null;
showWorkspaceHostHeader: boolean;
renderOverlays: boolean;
@@ -1484,7 +1407,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
sftpDefaultViewMode,
activeHost,
activeSessionId = null,
bannerRequiresSingleChannel = false,
focusedSessionId = null,
showWorkspaceHostHeader,
renderOverlays,
@@ -1692,18 +1614,33 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const canFollowTerminalCwd = useMemo(() => {
if (!onGetTerminalCwd || !followTerminalCwdHost) return false;
if (bannerRequiresSingleChannel) return false;
const proto = followTerminalCwdHost.protocol;
if (proto === "local" || proto === "serial") return false;
if (followTerminalCwdHost.id?.startsWith("local-") || followTerminalCwdHost.id?.startsWith("serial-")) return false;
return true;
}, [bannerRequiresSingleChannel, followTerminalCwdHost, onGetTerminalCwd]);
}, [followTerminalCwdHost, onGetTerminalCwd]);
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|| (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,
@@ -1945,6 +1882,10 @@ 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 -49
View File
@@ -1,4 +1,5 @@
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";
@@ -45,7 +46,7 @@ import {
resolveTerminalContextLineWindow,
type TerminalContextReader,
} from "../domain/terminalContextRead";
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from "../domain/host";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
@@ -81,7 +82,6 @@ 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";
@@ -161,12 +161,7 @@ import { registerTerminalSensitiveInputReader } from "./terminal/runtime/termina
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import {
applyUserCursorPreference,
applyUserCursorWidthPreference,
shouldApplyUserCursorPreference,
snapshotUserCursorPreference,
} from "./terminal/runtime/cursorPreference";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
createPromptLineBreakState,
@@ -201,7 +196,6 @@ import {
import {
createTerminalCwdTracker,
invalidateTerminalCwdAfterCommand,
shouldPreserveTerminalCwdAcrossCommand,
resolvePreferredTerminalCwd,
type TerminalCwdChangeMeta,
} from "./terminal/sftpCwd";
@@ -1140,15 +1134,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
allowRendererFallback?: boolean;
requireActiveShellCwd?: boolean;
}): Promise<string | undefined> => {
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
sessionId: sessionRef.current,
getSessionPwd: skipBackendPwd
? async () => ({ success: false })
: (id, pwdOptions) => terminalBackend.getSessionPwd(id, pwdOptions),
preferFreshBackend: skipBackendPwd ? false : options?.preferFreshBackend,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
allowRendererFallback: options?.allowRendererFallback,
requireActiveShellCwd: options?.requireActiveShellCwd,
});
@@ -1247,7 +1238,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
const auth = useTerminalAuthState({
host,
identities,
pendingAuthRef,
termRef,
onUpdateHost: handleUpdateHostFromTerminal,
@@ -1448,11 +1438,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
const sftpHost = next === "connected"
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
onStatusChange?.(sessionId, next, sftpHost === host ? undefined : sftpHost);
}, [clearAutoReconnect, host, identities, onStatusChange, sessionId]);
onStatusChange?.(sessionId, next);
}, [clearAutoReconnect, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
@@ -2384,21 +2371,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const cwdAwareOnCommandSubmitted = useCallback((
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
) => {
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
invalidateTerminalCwdAfterCommand(
terminalCwdTracker,
sessionId,
() => { knownCwdRef.current = undefined; },
onTerminalCwdChange,
);
}
const [command, hostId, hostLabel, submittedSessionId] = args;
onCommandSubmitted?.(command, hostId, hostLabel, submittedSessionId);
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
invalidateTerminalCwdAfterCommand(
terminalCwdTracker,
sessionId,
() => { knownCwdRef.current = undefined; },
onTerminalCwdChange,
);
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
}, [pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -3482,11 +3467,8 @@ 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(
sftpHost,
host,
initialPath,
undefined,
sessionId,
@@ -3501,7 +3483,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setShowSFTP(true);
}, [host, identities, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
const handleSendYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
@@ -3979,13 +3961,6 @@ 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,
@@ -3993,9 +3968,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleDrop,
isDraggingOver,
} = useTerminalDragDrop({
host: dragDropSftpHost,
resolvedLoginUsername: dragDropSftpHost === host ? resolvedLoginUsername : dragDropSftpHost.username,
resolvedSudoPassword: dragDropSudoPassword,
host,
resolvedLoginUsername,
resolvedSudoPassword: resolvedSudoAutofillPassword,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
@@ -4566,11 +4541,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
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, 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 ? (
<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 ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+25 -114
View File
@@ -20,7 +20,6 @@ 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';
@@ -28,7 +27,6 @@ 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';
@@ -117,7 +115,7 @@ import {
type RendererCwdSource,
type TerminalCwdChangeMeta,
} from './terminal/sftpCwd';
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
@@ -133,12 +131,9 @@ 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,
@@ -469,7 +464,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
useWorkspaceLayoutPresetState();
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string, sftpHostOverride?: Host) => {
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
// Preset persistence and resolution live in the application layer; the
// component only mounts the resolved panes into its UI state.
const resolved = resolveDefaultLayoutForSession(session);
@@ -502,7 +497,6 @@ 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
? {
@@ -522,7 +516,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
} as Host;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, sftpHostOverride ?? hostWithOverrides);
next.set(tabId, hostWithOverrides);
return next;
});
}
@@ -551,18 +545,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return true;
}, [persistWorkspaceLayoutAsDefault, t]);
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status'], sftpHostOverride?: Host) => {
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
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;
@@ -582,19 +569,6 @@ 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({
@@ -610,11 +584,7 @@ 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,
presetSession.id === sessionId ? sftpHostOverride : undefined,
)) return;
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
if (!targetPanel) return;
}
@@ -622,7 +592,6 @@ 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
? {
@@ -643,7 +612,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, presetSession.id === sessionId && sftpHostOverride ? sftpHostOverride : hostWithOverrides);
next.set(tabId, hostWithOverrides);
return next;
});
} else if (targetPanel === 'ai') {
@@ -763,15 +732,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
420,
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right' | 'bottom'>(
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
isSidePanelDockPosition,
);
const [sidePanelHeight, setSidePanelHeight, persistSidePanelHeight] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_HEIGHT,
360,
{ min: TERMINAL_SIDE_PANEL_MIN_HEIGHT, max: TERMINAL_SIDE_PANEL_MAX_HEIGHT },
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
// 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.
@@ -780,15 +744,6 @@ 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());
@@ -801,8 +756,6 @@ 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());
@@ -850,12 +803,6 @@ 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);
@@ -1041,7 +988,14 @@ 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 = isSameSftpHostSelection(currentHost, host);
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 currentLayout = sidePanelLayoutsRef.current.get(tabId);
const paneCount = currentLayout
@@ -1078,18 +1032,6 @@ 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,
@@ -1323,7 +1265,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
visibleSftpHost,
sessionHost,
globalSftpFollowTerminalCwd: sftpFollowTerminalCwdRef.current,
restrictExtraSshChannels: hostRestrictsExtraSshChannels(sessionHost),
})) return;
const osc7SignalAtCommand = terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0;
@@ -1339,14 +1280,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
const host = sessionHostsMapRef.current.get(sessionId);
if (!host) return false;
const hostDeviceClass = classifyDistroId(host.distro);
const hostIsNetworkDevice =
host.deviceType === 'network' || hostDeviceClass === 'network-device';
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId);
return shouldProbeSessionCwd({
isNetworkDevice: hostIsNetworkDevice,
isNetworkDevice,
remoteSshVersion: info?.remoteSshVersion,
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
});
},
onProbedCwd: (cwd) => {
@@ -1554,18 +1494,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
requireActiveShellCwd?: boolean;
}): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
rendererCwdSource: sessionId
? terminalRendererCwdSourceBySessionRef.current.get(sessionId)
: undefined,
sessionId,
getSessionPwd: skipBackendPwd
? async () => ({ success: false })
: (id, pwdOptions) => terminalBackend.getSessionPwd(id, pwdOptions),
preferFreshBackend: skipBackendPwd ? false : options?.preferFreshBackend,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
allowRendererFallback: options?.allowRendererFallback,
requireActiveShellCwd: options?.requireActiveShellCwd,
});
@@ -1630,35 +1566,16 @@ 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 sourceSessionId = getActiveTerminalSessionId();
const host = (sourceSessionId && sftpAuthHostBySessionId.get(sourceSessionId))
?? resolveSftpHostForTab(tabId);
const host = 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,
@@ -1680,7 +1597,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, sftpAuthHostBySessionId]);
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
// 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.
@@ -2501,7 +2418,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
persistSidePanelHeight,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleRunScriptFromPanel,
@@ -2615,10 +2531,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSidePanelOpenTabs,
setSidePanelLayouts,
setSidePanelWidth,
setSidePanelHeight,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab,
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
setSftpPendingUploadsForTab,
@@ -2627,14 +2541,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sidePanelLayouts,
sidePanelPosition,
sidePanelWidth,
sidePanelHeight,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpHostSourceSessionForTab,
sftpAuthHostBySessionId,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
+24 -9
View File
@@ -228,6 +228,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
tabBarPosition,
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
@@ -269,7 +270,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
const [rootTabsCompact, setRootTabsCompact] = useState(false);
const showWindowControls = !isMacClient;
const tabsAtBottom = tabBarPosition === 'bottom';
const showWindowControls = !isMacClient && !tabsAtBottom;
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
@@ -997,12 +999,24 @@ 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 bg-secondary app-drag"
className="relative w-full shrink-0 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)))',
}}
@@ -1013,12 +1027,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="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<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="h-9 flex items-end gap-0 app-drag overflow-visible"
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
style={{
...dragRegionStyle,
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
paddingRight: showWindowControls ? 0 : 12,
}}
>
@@ -1066,7 +1080,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{hasHostTreeToggleSurface && (
<div
ref={hostTreeToggleSlotRef}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
data-section="top-tabs-host-tree-toggle"
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
style={noDragRegionStyle}
@@ -1176,7 +1190,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
>
@@ -1188,9 +1202,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</div>
)}
{/* Right controls share the tab row. */}
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
<div
className="flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible self-end"
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
style={dragRegionStyle}
data-section="top-tabs-toolbar-actions"
>
@@ -1265,6 +1279,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
</div>
</div>
</>
);
};
+3 -14
View File
@@ -206,12 +206,6 @@ 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 */
@@ -277,7 +271,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
agentName,
placeholder,
modelPresets = [],
allowCustomModelEntry = true,
selectedModelId,
onModelSelect,
files = [],
@@ -1533,7 +1526,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
) : (
<Cpu size={11} className="text-muted-foreground/64" />
)}
<span className={`truncate min-w-0 ${modelChipMaxWidth}`} title={modelLabel}>{modelLabel}</span>
<span className={`truncate min-w-0 ${modelChipMaxWidth}`}>{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{contextUsage && (
@@ -1592,22 +1585,19 @@ 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, options) => {
onSelectModel={(modelId) => {
const preset = modelPresets.find((item) => item.id === modelId);
const nextId = preset
? resolveModelSelectionWithThinking(preset, selectedPresetThinking)
: modelId;
onModelSelect?.(nextId);
setModelPrefs(rememberComposerRecentModel(pickerScope, options?.custom
? { modelId, custom: true }
: { modelId }));
setModelPrefs(rememberComposerRecentModel(pickerScope, { modelId }));
closeAllMenus();
}}
onTogglePinned={(entry) => {
@@ -1805,7 +1795,6 @@ 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
+2 -45
View File
@@ -61,51 +61,8 @@ test('external agent picker lists presets without a provider column', () => {
assert.doesNotMatch(html, /ai\.chat\.providers/);
});
test('custom model action is offered in provider-switcher and preset modes', () => {
test('custom model action is only offered in Catty provider-switcher mode', () => {
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
// 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, /const showCustom = Boolean\(\s*hasProviders/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\)"/);
});
+5 -41
View File
@@ -2,7 +2,6 @@ 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,
@@ -23,14 +22,8 @@ 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, options?: { custom?: boolean }) => void;
onSelectModel?: (modelId: string) => void;
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
}
@@ -63,7 +56,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" title={model.name}>{model.name}</span>
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
</button>
<button
type="button"
@@ -90,7 +83,6 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
selectedModelId,
modelPresets = [],
prefs,
allowCustomEntry = true,
onSelectProviderModel,
onSelectModel,
onTogglePinned,
@@ -141,51 +133,23 @@ 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(
allowCustomEntry
hasProviders
&& 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;
}
// 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);
onSelectModel?.(modelId);
};
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
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 } : {}) }
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
);
if (hasProviders && view === 'providers') {
-17
View File
@@ -345,23 +345,6 @@ test('buildManagedAgentState stores OPENCODE_BIN for opencode', () => {
});
});
test('buildManagedAgentState stores MIMOCODE_BIN for mimo', () => {
const state = buildManagedAgentState(
[],
'catty',
'mimo',
{ path: '/opt/homebrew/bin/mimo', version: '1.0.0', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].id, 'discovered_mimo');
assert.equal(state.agents[0].command, '/opt/homebrew/bin/mimo');
assert.equal(state.agents[0].sdkBackend, 'mimo');
assert.deepEqual(state.agents[0].env, {
MIMOCODE_BIN: '/opt/homebrew/bin/mimo',
});
});
test('updateCodebuddyManagedEnv creates a disabled managed entry before CLI detection', () => {
const state = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
@@ -50,34 +50,4 @@ test('inferArtifactToolNameFromCliArgs maps Netcatty CLI artifact commands', ()
}),
'host_get',
);
assert.equal(
inferArtifactToolNameFromCliArgs({
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" notes import --file-name runbook.md --content-stdin --json'`,
}),
'vault_notes_import',
);
assert.equal(
inferArtifactToolNameFromCliArgs({
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" --json notes list'`,
}),
'vault_notes_list',
);
assert.equal(
inferArtifactToolNameFromCliArgs({
command: 'netcatty-tool-cli notes list --json',
}),
'vault_notes_list',
);
assert.equal(
inferArtifactToolNameFromCliArgs({
command: 'netcatty-tool-cli notes --group "ops team" import --content-stdin --json',
}),
'vault_notes_import',
);
assert.equal(
inferArtifactToolNameFromCliArgs({
command: '& "C:\\Program Files\\Netcatty\\netcatty-tool-cli.cmd" notes import --attachment-index 0 --json',
}),
'vault_notes_import',
);
});
@@ -6,8 +6,6 @@ const KNOWN_ARTIFACT_TOOL_NAMES = [
'vault_notes_update',
'vault_notes_get',
'vault_notes_list',
'vault_notes_delete',
'vault_notes_import',
'vault_hosts_create',
'vault_hosts_import',
'vault_hosts_list',
@@ -32,16 +30,8 @@ const KNOWN_ARTIFACT_TOOL_NAMES = [
'scripts_targets_set',
] as const;
const CLI_FLAGS_WITHOUT_VALUES = new Set(['--json', '--content-stdin', '--documents-stdin']);
const CLI_ARTIFACT_TOOL_NAMES = new Map<string, string>([
['vault host get', 'host_get'],
['notes list', 'vault_notes_list'],
['notes get', 'vault_notes_get'],
['notes create', 'vault_notes_create'],
['notes update', 'vault_notes_update'],
['notes delete', 'vault_notes_delete'],
['notes import', 'vault_notes_import'],
]);
function readCommandString(args: Record<string, unknown> | undefined): string | null {
@@ -94,26 +84,6 @@ export function normalizeArtifactToolName(toolName: string | undefined): string
return trimmed;
}
function collectCliCommandWords(afterCli: string): string[] {
const commandWords: string[] = [];
const parts = afterCli.match(/(?:[^\s"'\\]|\\.|"(?:\\.|[^"\\])*"|'[^']*')+/g) ?? [];
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
if (!part) continue;
if (part.startsWith('-')) {
// Flags may appear before or between command words. Skip a valued flag's
// quoted or unquoted value so note content is never a command word.
if (CLI_FLAGS_WITHOUT_VALUES.has(part)) continue;
const next = parts[index + 1];
if (next && !next.startsWith('-')) index += 1;
continue;
}
commandWords.push(part);
if (CLI_ARTIFACT_TOOL_NAMES.has(commandWords.join(' ')) || commandWords.length === 3) break;
}
return commandWords;
}
export function inferArtifactToolNameFromCliArgs(
args: Record<string, unknown> | undefined,
): string | undefined {
@@ -121,11 +91,15 @@ export function inferArtifactToolNameFromCliArgs(
if (!command) return undefined;
const unwrapped = unwrapShellCommand(command);
const cliMatch = unwrapped.match(/(?:^|\s|["'])(?:\S*[\\/])?netcatty-tool-cli(?:\.(?:cjs|cmd))?(?=["'\s]|$)([\s\S]*)$/);
const cliMatch = unwrapped.match(/(?:^|\s|["'])(?:\S*\/)?netcatty-tool-cli(?:\.(?:cjs|cmd))?(?=["'\s]|$)([\s\S]*)$/);
if (!cliMatch) return undefined;
const afterCli = stripWrappingQuote(cliMatch[1] ?? '').replace(/^["']?\s*/, '');
const commandKey = collectCliCommandWords(afterCli).join(' ');
const commandKey = afterCli
.split(/\s+/)
.filter((part) => part && !part.startsWith('-'))
.slice(0, 3)
.join(' ');
return CLI_ARTIFACT_TOOL_NAMES.get(commandKey);
}
@@ -11,13 +11,6 @@ test('resolveVaultArtifactVisualKind distinguishes note vs host tools', () => {
),
'noteCreate',
);
assert.equal(
resolveVaultArtifactVisualKind(
{ kind: 'vault.note', noteId: 'n1', title: 'Doc' },
'vault_notes_import',
),
'noteImport',
);
assert.equal(
resolveVaultArtifactVisualKind(
{ kind: 'vault.host', hostId: 'h1', label: 'Web', hostname: '10.0.0.1' },
@@ -26,7 +26,6 @@ export type VaultArtifactVisualKind =
| 'noteUpdate'
| 'noteRead'
| 'noteList'
| 'noteImport'
| 'host'
| 'hostCreate'
| 'hostImport'
@@ -55,7 +54,6 @@ const VISUAL_STYLES: Record<VaultArtifactVisualKind, { wrapper: string; icon: st
noteUpdate: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
noteRead: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/80' },
noteList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
noteImport: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
host: { wrapper: 'bg-emerald-500/12', icon: 'text-emerald-400' },
hostCreate: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
hostImport: { wrapper: 'bg-amber-500/12', icon: 'text-amber-400' },
@@ -85,7 +83,6 @@ export function resolveVaultArtifactVisualKind(
if (artifact.kind === 'error') return 'error';
if (artifact.kind === 'vault.note') {
if (toolName === 'vault_notes_import') return 'noteImport';
if (toolName === 'vault_notes_create') return 'noteCreate';
if (toolName === 'vault_notes_update') return 'noteUpdate';
return 'noteRead';
@@ -101,7 +98,6 @@ export function resolveVaultArtifactVisualKind(
}
if (artifact.kind === 'vault.summary') {
if (toolName === 'vault_notes_import') return 'noteImport';
if (artifact.section === 'notes') return 'noteList';
if (artifact.section === 'hosts') return 'hostList';
if (artifact.section === 'snippets') return 'snippetList';
@@ -143,8 +139,6 @@ function renderVisualIcon(kind: VaultArtifactVisualKind): React.ReactNode {
return <FileText size={ARTIFACT_ICON_SIZE} className={className} />;
case 'noteList':
return <Library size={ARTIFACT_ICON_SIZE} className={className} />;
case 'noteImport':
return <FolderInput size={ARTIFACT_ICON_SIZE} className={className} />;
case 'host':
return <Server size={ARTIFACT_ICON_SIZE} className={className} />;
case 'hostCreate':
@@ -16,20 +16,6 @@ test('parseVaultToolArtifact maps note create results', () => {
});
});
test('parseVaultToolArtifact maps a single note import', () => {
const artifact = parseVaultToolArtifact('vault_notes_import', {
ok: true,
importedCount: 1,
notes: [{ id: 'note-2', title: 'Runbook', group: 'ops' }],
});
assert.deepEqual(artifact, {
kind: 'vault.note',
noteId: 'note-2',
title: 'Runbook',
group: 'ops',
});
});
test('parseVaultToolArtifact maps single host create to host artifact', () => {
const artifact = parseVaultToolArtifact('vault_hosts_create', {
ok: true,
@@ -84,7 +84,6 @@ const VAULT_ARTIFACT_TOOL_NAMES = new Set([
'vault_notes_update',
'vault_notes_get',
'vault_notes_list',
'vault_notes_import',
'vault_hosts_create',
'vault_hosts_import',
'vault_hosts_list',
@@ -222,13 +221,6 @@ export function parseVaultToolArtifact(
const notes = Array.isArray(payload.notes) ? payload.notes : [];
return { kind: 'vault.summary', section: 'notes', count: notes.length };
}
case 'vault_notes_import': {
const notes = Array.isArray(payload.notes) ? payload.notes : [];
if (notes.length === 1) return parseNoteArtifact(notes[0]);
const count = readNumber(payload.importedCount) ?? notes.length;
if (count <= 0) return null;
return { kind: 'vault.summary', section: 'notes', count };
}
case 'vault_hosts_create':
case 'vault_hosts_import': {
const preview = parsePreviewHosts(payload.previewHosts);
+5 -7
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 { detectSshKeyType } from '../../domain/keyTypeDetect';
import { detectKeyType } from './utils';
interface ImportKeyPanelProps {
draftKey: Partial<SSHKey>;
@@ -38,15 +38,14 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detected = detectSshKeyType(content);
const detectedType = detectKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detected.type,
keySize: detected.keySize,
type: detectedType,
});
}
};
@@ -65,15 +64,14 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detected = detectSshKeyType(content);
const detectedType = detectKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detected.type,
keySize: detected.keySize,
type: detectedType,
});
}
};
+4 -2
View File
@@ -5,7 +5,6 @@
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';
/**
@@ -28,7 +27,10 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
* Detect key type from private key content
*/
export const detectKeyType = (privateKey: string): KeyType => {
return detectSshKeyType(privateKey).type;
const pk = privateKey.toLowerCase();
if (pk.includes('rsa')) return 'RSA';
if (pk.includes('ecdsa') || pk.includes('ec ')) return 'ECDSA';
return 'ED25519';
};
/**
+6 -39
View File
@@ -226,7 +226,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codebuddy: string;
opencode: string;
grok: string;
mimo: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
@@ -299,12 +298,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [grokCustomPath, setGrokCustomPath] = useState(() => initialManagedPathsRef.current?.grok ?? "");
const [isResolvingGrok, setIsResolvingGrok] = useState(false);
const [mimoPathInfo, setMimoPathInfo] = useState<AgentPathInfo | null>(
() => getSavedManagedAgentPathInfo(externalAgents, "mimo"),
);
const [mimoCustomPath, setMimoCustomPath] = useState(() => initialManagedPathsRef.current?.mimo ?? "");
const [isResolvingMimo, setIsResolvingMimo] = useState(false);
const codebuddyManagedAgent = useMemo(
() => externalAgents.find((a) => a.id === "discovered_codebuddy"),
[externalAgents],
@@ -355,7 +348,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
return () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo"] as ManagedAgentKey[]) {
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
}
};
@@ -378,9 +371,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setCodebuddyPathInfo
: agentKey === "opencode"
? setOpencodePathInfo
: agentKey === "grok"
? setGrokPathInfo
: setMimoPathInfo;
: setGrokPathInfo;
setInfo(result);
@@ -423,9 +414,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setIsResolvingCodebuddy
: agentKey === "opencode"
? setIsResolvingOpencode
: agentKey === "grok"
? setIsResolvingGrok
: setIsResolvingMimo;
: setIsResolvingGrok;
setResolving(true);
const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1;
@@ -466,9 +455,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? setCodebuddyPathInfo
: agentKey === "opencode"
? setOpencodePathInfo
: agentKey === "grok"
? setGrokPathInfo
: setMimoPathInfo;
: setGrokPathInfo;
setInfo(result);
return result;
}
@@ -507,7 +494,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
{ key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" },
{ key: "opencode", delayMs: 1560, path: initialPaths?.opencode ?? "" },
{ key: "grok", delayMs: 1840, path: initialPaths?.grok ?? "" },
{ key: "mimo", delayMs: 2120, path: initialPaths?.mimo ?? "" },
];
const cancelTasks = tasks
.filter((task) => !autoResolvedAgentStateRef.current[task.key])
@@ -768,9 +754,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
? opencodeCustomPath
: agentKey === "grok"
? grokCustomPath
: agentKey === "mimo"
? mimoCustomPath
: "";
: "";
const result = await resolveAgentPath(agentKey, customPath, {
refreshShellEnv: true,
commandSource: customPath.trim() ? "manual" : "auto",
@@ -782,7 +766,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codexPath: result?.path || customPath.trim() || undefined,
});
}
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, grokCustomPath, mimoCustomPath, resolveAgentPath, refreshCodexIntegration]);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, grokCustomPath, resolveAgentPath, refreshCodexIntegration]);
const handleResetCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
if (agentKey === "codex") {
@@ -797,8 +781,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setOpencodeCustomPath("");
} else if (agentKey === "grok") {
setGrokCustomPath("");
} else if (agentKey === "mimo") {
setMimoCustomPath("");
}
const result = await resolveAgentPath(agentKey, "", {
@@ -1225,21 +1207,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
/>
</SettingsSection>
<SettingsSection
title={t('ai.mimo.title')}
leading={<AgentIconBadge agent={{ id: "mimo", icon: "mimo", name: "MiMo Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CopilotCliCard
pathInfo={mimoPathInfo}
isResolvingPath={isResolvingMimo}
customPath={mimoCustomPath}
onCustomPathChange={setMimoCustomPath}
onRecheckPath={() => void handleCheckCustomPath("mimo")}
onResetPath={() => void handleResetCustomPath("mimo")}
i18nPrefix="ai.mimo"
/>
</SettingsSection>
{agentOptions.length > 1 ? (
<SettingsSection anchorId="ai-default-agent" title={t('ai.defaultAgent')}>
<SettingCard>
@@ -60,6 +60,8 @@ 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;
@@ -129,6 +131,8 @@ function SettingsAppearanceTab(props: {
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
tabBarPosition,
setTabBarPosition,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
@@ -479,6 +483,29 @@ 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')}
@@ -10,15 +10,6 @@ test("terminal settings expose cursor line highlight toggle", () => {
assert.match(source, /updateTerminalSetting\("highlightCursorLine", v\)/);
});
test("terminal settings expose a bounded bar cursor width control", () => {
assert.match(source, /settings\.terminal\.cursor\.barWidth/);
assert.match(source, /min=\{1\}/);
assert.match(source, /max=\{4\}/);
assert.match(source, /value=\{terminalSettings\.cursorBarWidth\}/);
assert.match(source, /aria-label=\{t\("settings\.terminal\.cursor\.barWidth"\)\}/);
assert.match(source, /updateTerminalSetting\("cursorBarWidth", parseInt\(e\.target\.value\)\)/);
});
test("terminal settings hide terminal theme pickers while following app theme", () => {
assert.match(source, /\{!followAppTerminalTheme && \(/);
assert.doesNotMatch(source, /settings\.terminal\.theme\.followingTheme/);
@@ -602,17 +602,6 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-font-ligatures"
label={t("settings.terminal.font.ligatures")}
description={t("settings.terminal.font.ligatures.desc")}
>
<Toggle
checked={terminalSettings.fontLigatures}
onChange={(v) => updateTerminalSetting("fontLigatures", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-font-line-padding"
label={t("settings.terminal.font.linePadding")}
@@ -663,28 +652,6 @@ function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
anchorId="terminal-cursor-bar-width"
label={t("settings.terminal.cursor.barWidth")}
description={t("settings.terminal.cursor.barWidth.desc")}
>
<div className="flex items-center gap-2">
<input
type="range"
aria-label={t("settings.terminal.cursor.barWidth")}
min={1}
max={4}
step={1}
value={terminalSettings.cursorBarWidth}
onChange={(e) => updateTerminalSetting("cursorBarWidth", parseInt(e.target.value))}
className="w-24 accent-primary"
/>
<span className="w-6 text-center text-sm text-muted-foreground">
{terminalSettings.cursorBarWidth}px
</span>
</div>
</SettingRow>
<SettingRow anchorId="terminal-cursor-blink" label={t("settings.terminal.cursor.blink")}>
<Toggle
checked={terminalSettings.cursorBlink}
@@ -232,17 +232,6 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
/>
</SettingRow>
<SettingRow
anchorId="terminal-shift-enter-force-text"
label={t("settings.terminal.behavior.shiftEnterForceText")}
description={t("settings.terminal.behavior.shiftEnterForceText.desc")}
>
<Toggle
checked={terminalSettings.shiftEnterForceText ?? false}
onChange={(v) => updateTerminalSetting("shiftEnterForceText", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-clear-wipes-scrollback"
label={t("settings.terminal.behavior.clearWipesScrollback")}
@@ -14,7 +14,7 @@ export const CopilotCliCard: React.FC<{
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath?: () => void;
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok" | "ai.mimo";
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
allowEmptyCheck?: boolean;
showCustomPathInput?: boolean;
/** Grok only: ACP (default) vs headless streaming-json. */
@@ -111,9 +111,7 @@ export function buildManagedAgentState(
? { ...(existingManaged?.env ?? {}), CODEBUDDY_CODE_PATH: pathInfo.path }
: agentKey === "opencode"
? { ...(existingManaged?.env ?? {}), OPENCODE_BIN: pathInfo.path }
: agentKey === "mimo"
? { ...(existingManaged?.env ?? {}), MIMOCODE_BIN: pathInfo.path }
: existingManaged?.env;
: existingManaged?.env;
const cursorAuthMode = agentKey === "cursor"
? (existingManaged?.cursorAuthMode
?? (pathInfo.authSource === "cli-login" || pathInfo.cliLoginOk ? "cli-login" : "api-key"))
@@ -237,6 +235,5 @@ export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
mimo: getAutoManagedAgentStoredPath(agents, "mimo") ?? "",
};
}
-6
View File
@@ -235,12 +235,6 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
icon: "grok",
sdkBackend: "grok",
},
mimo: {
name: "MiMo Code",
args: [],
icon: "mimo",
sdkBackend: "mimo",
},
};
// ---------------------------------------------------------------------------
-10
View File
@@ -51,7 +51,6 @@ interface SftpPaneDialogsProps {
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
deleteTargets: string[];
deleteError?: string | null;
handleDelete: () => void;
isDeleting: boolean;
// Host picker (connected view)
@@ -105,7 +104,6 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
showDeleteConfirm,
setShowDeleteConfirm,
deleteTargets,
deleteError,
handleDelete,
isDeleting,
showHostPicker,
@@ -353,24 +351,16 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
))}
</div>
) : null}
{deleteError ? (
<p className="break-words text-xs text-destructive [overflow-wrap:anywhere]">
{deleteError}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
>
{t("common.cancel")}
</Button>
<Button
ref={deleteConfirmButtonRef}
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
@@ -12,14 +12,13 @@ const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('reconnect listing update keeps scroll, while sorting and filters reveal the selection', async (t) => {
test('refresh keeps scroll, while explicit 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,
@@ -40,9 +39,9 @@ test('reconnect listing update keeps scroll, while sorting and filters reveal th
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: [selected, added] }, sortedDisplayFiles: [added, selected] };
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 1, 'a reconnect listing update must not pull the viewport back to the selection');
assert.equal(scrolls, 1, 'refresh 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');
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more