Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 c300595fb8 fix: skip terminal pwd exec for network-vendor banners when opening SFTP
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:50:17 +00:00
Cursor Agentand陈大猫 d5395abc38 fix: block follow-cwd probes on dedicated SFTP logins
Network-device mode and vendor SSH banners already dial SFTP separately,
but follow-terminal-cwd still opened an exec channel on the terminal
transport. Gate that probe with the same dedicated-login decision.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:26:48 +00:00
Cursor Agentand陈大猫 ade3f0940a fix: dial SFTP separately on network-device SSH sessions
Huawei and Ruijie switches drop the shell when SFTP opens on the terminal TCP session. Use the existing dedicated login for network-device mode and network-vendor banners without marking those hosts single-channel bastions.

Fixes #3631

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:17:55 +00:00
43 changed files with 168 additions and 1411 deletions

No files matched your search

+3 -19
View File
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.',
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
'settings.appearance.customCss.help.title': 'Custom CSS guide',
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
@@ -440,22 +440,6 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
The Vaults tab stays flat and does not use the fill.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault
- \`vault-view\`
@@ -549,7 +533,7 @@ Highlight the focused split pane:
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -728,7 +712,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. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.',
'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.',
+3 -19
View File
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Paleta oscura',
'settings.appearance.customCss': 'CSS personalizado',
'settings.appearance.customCss.desc':
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.',
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
@@ -355,22 +355,6 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
La pestaña Vaults permanece plana y no usa el relleno.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault
- \`vault-view\`
@@ -464,7 +448,7 @@ Resalta el panel dividido enfocado:
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
'settings.appearance.language': 'Idioma',
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
'settings.appearance.uiFont': 'Fuente de la interfaz',
@@ -643,7 +627,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. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.',
'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.',
+3 -19
View File
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
@@ -423,22 +423,6 @@ export const ruCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
Вкладка Vaults остаётся плоской и не использует заливку.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Хранилище
- \`vault-view\`
@@ -518,7 +502,7 @@ export const ruCoreMessages: Messages = {
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
@@ -679,7 +663,7 @@ export const ruCoreMessages: Messages = {
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
+2 -18
View File
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
'使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
@@ -444,22 +444,6 @@ export const zhCNCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
Vaults 标签保持扁平,不使用这块背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保险库
- \`vault-view\`
@@ -553,7 +537,7 @@ Vaults 标签保持扁平,不使用这块背景。
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
+1 -1
View File
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
'使用 \\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。',
+2 -18
View File
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主題',
'settings.appearance.customCss': '自訂 CSS',
'settings.appearance.customCss.desc':
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
'使用自訂 CSS 個人化介面,修改會立即生效。',
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
@@ -446,22 +446,6 @@ export const zhTWCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
Vaults 分頁維持扁平,不使用這塊背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保險庫
- \`vault-view\`
@@ -555,7 +539,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型',
+1 -1
View File
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
'使用 \\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。',
@@ -10,14 +10,12 @@ import {
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
@@ -216,76 +214,3 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\n",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\e\\r",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
shiftEnterNewlineText: "\\r",
scrollback: 1000,
});
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
const store = installMemoryLocalStorage();
try {
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -28
View File
@@ -1,15 +1,8 @@
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
type HotkeyScheme,
type SessionLogFormat,
type TerminalSettings,
} from '../../domain/models';
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
@@ -98,26 +91,6 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
/**
* One-time migration for the Shift+Enter default flip (#3770). Builds before
* that change materialized `\\n` into saved settings, so upgraders kept a
* bare newline and TUI CLIs still submitted. Rewrite only that old default
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
* is left alone. Custom text is never rewritten. Sync and import do not
* read this marker; an old `\\n` from another device stays as stored.
*/
export function migrateMaterializedShiftEnterNewlineDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
...stored,
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
});
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -530,12 +530,8 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
await act(async () => renderer?.unmount());
});
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
// Regression for #3588: command submit clears cwd to null and schedules a
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
// gap — that race aborted the navigate and left the panel stuck.
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
const navigatedPaths: string[] = [];
let probeCalls = 0;
const connection = {
id: "conn-1",
hostId: "host-1",
@@ -554,15 +550,20 @@ test("regular follow waits for trusted live cwd instead of polling after command
},
},
};
let activeSessionId = "session-a";
let activeTerminalCwd: string | null = "/home/alice";
let activeTerminalCwdTrusted = false;
let resolveCwd: ((cwd: string) => void) | null = null;
let deferProbe = false;
let renderer: ReactTestRenderer | null = null;
const onGetTerminalCwd = () => {
if (!deferProbe) return Promise.resolve("/root/session-a");
return new Promise<string>((resolve) => { resolveCwd = resolve; });
};
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId: "session-1",
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
@@ -574,11 +575,7 @@ test("regular follow waits for trusted live cwd instead of polling after command
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd: async () => {
probeCalls += 1;
// First-open may probe; keep the pane where it already is.
return connection.currentPath;
},
onGetTerminalCwd,
onPendingFollowOverride: () => {},
sftpRef,
});
@@ -589,28 +586,23 @@ test("regular follow waits for trusted live cwd instead of polling after command
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
const probesAfterOpen = probeCalls;
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
assert.deepEqual(navigatedPaths, []);
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
// Command-submit style clear: no trusted cwd yet.
deferProbe = true;
activeTerminalCwd = null;
activeTerminalCwdTrusted = false;
await act(async () => {
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
assert.deepEqual(navigatedPaths, []);
assert.ok(resolveCwd);
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
activeTerminalCwd = "/etc";
activeTerminalCwdTrusted = true;
activeSessionId = "session-b";
await act(async () => renderer?.update(React.createElement(Probe)));
await act(async () => {
renderer?.update(React.createElement(Probe));
resolveCwd?.("/root/from-session-a");
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/etc"]);
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
await act(async () => renderer?.unmount());
});
@@ -302,12 +302,6 @@ export function useSftpFollowTerminalCwd({
&& previousOriginId !== expectedSessionId,
);
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
// backend-strict probe, or inferred). Polling preferFreshBackend here while
// cwd is null/untrusted races that probe: navigate starts, the probe then
// invalidates the sync generation, abort restores the previous path, and
// without a connectionPath effect dependency the panel stays put (#3588).
// Manual "Go to terminal directory" and first-open sync still probe.
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
if (!terminalCwd && originChanged) {
@@ -317,6 +311,12 @@ export function useSftpFollowTerminalCwd({
homeDir: sftpRef.current.leftPane.connection?.homeDir,
currentPath: sftpRef.current.leftPane.connection?.currentPath,
});
} else if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
});
}
if (!terminalCwd) return;
if (!shouldApplyFollowTerminalCwdSyncResult({
@@ -430,7 +430,6 @@ export function useSftpFollowTerminalCwd({
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionPath,
connectionStatus,
effectiveFollowTerminalCwd,
focusedSessionId,
@@ -56,28 +56,6 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
unsubscribe();
});
test('enter reconnect stays off until the session allows it and does not queue', () => {
const registry = createTerminalReconnectRegistry();
const requests: string[] = [];
assert.equal(registry.requestEnterReconnect('session-1'), false);
registry.register('session-1', () => requests.push('session-1'));
assert.deepEqual(requests, []);
registry.setEnterReconnectEnabled('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), true);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', false);
registry.setEnterReconnectEnabled('session-1', false);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
});
test('unregistering the current handler clears active reconnect state', () => {
const registry = createTerminalReconnectRegistry();
const unregister = registry.register('session-1', () => {});
+1 -22
View File
@@ -4,7 +4,6 @@ export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
const listeners = new Set<() => void>();
const emit = () => {
@@ -52,27 +51,7 @@ export const createTerminalReconnectRegistry = () => {
return true;
};
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
// request(); this path stays off during auth, host-key, and blocking overlays.
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
if (enabled) enterReconnectEnabledIds.add(sessionId);
else enterReconnectEnabledIds.delete(sessionId);
};
const requestEnterReconnect = (sessionId: string): boolean => {
if (!enterReconnectEnabledIds.has(sessionId)) return false;
return request(sessionId);
};
return {
register,
request,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
isActive,
subscribe,
};
return { register, request, setActive, isActive, subscribe };
};
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
-2
View File
@@ -151,7 +151,6 @@ import {
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -361,7 +360,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
migrateMaterializedShiftEnterNewlineDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
-20
View File
@@ -294,26 +294,6 @@ test("session close button double click stays on the close button", () => {
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("main window controls keep mouse presses from taking focus", () => {
const windowControlsSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WindowControls"),
topTabItemsSource.indexOf("WindowControls.displayName"),
);
assert.match(
windowControlsSource,
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
);
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
const closeButton = windowControlsSource.match(
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
)?.[0] ?? "";
assert.equal(closeButton.includes("onMouseDown"), false);
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
>
<Input
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
placeholder="\\e\\r"
placeholder="\\n"
className="w-56 font-mono"
spellCheck={false}
/>
@@ -6,7 +6,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { TerminalComposeBar } from './TerminalComposeBar';
import { TooltipProvider } from '../ui/tooltip';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
@@ -103,95 +102,3 @@ test('compose bar preserves a draft on pane focus and records only successful as
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'localStorage']) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
}
const sessionId = 'compose-enter-reconnect';
const reconnects: string[] = [];
const unregister = terminalReconnectRegistry.register(sessionId, () => {
reconnects.push(sessionId);
});
t.after(() => {
unregister();
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
dom.window.close();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const createTextarea = () => ({
value: '', selectionStart: 0, selectionEnd: 0,
focus() {},
setSelectionRange() {},
});
let textarea = createTextarea();
const sent: string[] = [];
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<TooltipProvider>
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
</TooltipProvider>,
{ createNodeMock: (el) => {
if (el.type !== 'textarea') return null;
textarea = createTextarea();
return textarea;
} },
);
});
const key = (name: string, extra: Record<string, unknown> = {}) => {
let prevented = false;
root.root.findByType('textarea').props.onKeyDown({
key: name,
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
nativeEvent: {},
currentTarget: textarea,
preventDefault() { prevented = true; },
...extra,
});
return prevented;
};
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, []);
assert.equal(textarea.value, '');
textarea.value = 'echo hi';
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
assert.equal(textarea.value, '');
textarea.value = 'keep me';
assert.equal(key('Enter', { shiftKey: true }), false);
assert.equal(textarea.value, 'keep me');
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
textarea.value = '';
assert.equal(key('Enter', { ctrlKey: true }), true);
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
assert.deepEqual(reconnects, [sessionId]);
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
await act(async () => { root.unmount(); });
});
+1 -12
View File
@@ -5,7 +5,6 @@
*/
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
@@ -497,22 +496,12 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
}
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
// Plain Enter on an empty draft reconnects a disconnected session.
// Modified Enter and a non-empty draft keep the existing send path (#3767).
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
if (
plainEnter
&& !e.currentTarget.value
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
) {
return;
}
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, navigate, onClose, sessionId]);
}, [handleSend, navigate, onClose]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
+4 -4
View File
@@ -220,6 +220,7 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
key: "Enter",
status: "disconnected",
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -233,6 +234,7 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
key: "Enter",
status: "disconnected" as const,
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -241,16 +243,14 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
// Open search and an open compose bar must not globally suppress Enter
// reconnect / the hint (#2546 / #3767).
// Open search must not globally suppress Enter reconnect / the hint (#2546).
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
});
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
+11 -15
View File
@@ -7,7 +7,6 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
import type { HostInfoBarTitleMode } from '../../domain/models';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { OSC7_SETUP_TARGETS } from './osc7Setup';
@@ -185,6 +184,7 @@ export function shouldReconnectTerminalOnEnterKey({
key,
status,
hasRetryHandler,
isComposeBarOpen,
needsAuth,
needsHostKeyVerification,
hasBlockingOverlay,
@@ -198,6 +198,7 @@ export function shouldReconnectTerminalOnEnterKey({
key: string;
status?: string;
hasRetryHandler: boolean;
isComposeBarOpen: boolean;
needsAuth: boolean;
needsHostKeyVerification: boolean;
hasBlockingOverlay: boolean;
@@ -208,15 +209,15 @@ export function shouldReconnectTerminalOnEnterKey({
shiftKey?: boolean;
isComposing?: boolean;
}): boolean {
// Search and compose bars are not global gates. While disconnected, plain
// Enter still reconnects from the terminal and the disconnected dialog.
// Capture still skips real controls outside xterm (compose textarea, auth,
// buttons). Search input is allow-listed so an open search bar cannot hide
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
// Search-bar open state is intentionally not a global gate. While disconnected,
// find-next is less useful than reconnect; the capture handler still refuses
// Enter only when a real interactive control outside xterm owns the event
// (compose/auth/buttons). The terminal search input is allow-listed so an open
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
return key === "Enter"
&& status === "disconnected"
&& hasRetryHandler
&& !isComposeBarOpen
&& !needsAuth
&& !needsHostKeyVerification
&& !hasBlockingOverlay
@@ -528,24 +529,18 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
key: "Enter",
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
isReconnectActive,
});
// Publish Enter-reconnect so an empty compose draft can reconnect this
// session, including the workspace compose bar (#3767).
useEffect(() => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
return () => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
};
}, [sessionId, showEnterReconnectHint]);
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectTerminalOnEnterKey({
key: event.key,
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
@@ -568,6 +563,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
auth.needsAuth,
handleRetry,
hasBlockingReconnectOverlay,
isComposeBarOpen,
isReconnectActive,
needsHostKeyVerification,
status,
@@ -389,23 +389,6 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
});
test("open compose bar does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
const gateBody = viewSource.slice(gateIndex, bodyEnd);
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
assert.match(
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
/requestEnterReconnect\(sessionId\)/,
);
assert.match(
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
/data-section='terminal-compose-bar'/,
);
});
test("open terminal search does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
@@ -419,7 +402,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
);
});
test("terminal view receives the effective compose bar state", () => {
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
const viewIndex = source.indexOf("<TerminalView ctx={{");
@@ -430,7 +413,7 @@ test("terminal view receives the effective compose bar state", () => {
assert.notEqual(passEffectiveIndex, -1);
assert.ok(
effectiveDefinitionIndex < passEffectiveIndex,
"TerminalView must receive the visible workspace compose state",
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
);
});
@@ -114,8 +114,6 @@ import { handleSerialLineModeInput } from "./serialLineInput";
import { isTerminalReportSequence } from "./terminalReportSequence";
import {
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
readNativeKittyKeyboardFlags,
resolveShiftEnterText,
shouldClaimShiftEnterForText,
shouldSendShiftEnterText,
@@ -598,12 +596,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
// 9001. Preserve browser key modifiers for native Windows applications
// instead of collapsing them into legacy VT bytes.
// kittyKeyboard lets an app opt into xterm's CSI u. Netcatty's own
// handlers, when that setting is on, register later and consume those
// sequences first, so the two parsers do not both apply the protocol.
vtExtensions: {
win32InputMode: windowsPty?.backend === "conpty",
kittyKeyboard: true,
},
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
@@ -2534,8 +2528,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
shouldClaimShiftEnterForText(e, ctx.terminalSettingsRef.current, {
win32InputMode: term.modes.win32InputMode,
kittySequenceForKeyDown,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
})
) {
const id = ctx.sessionRef.current;
@@ -3131,8 +3123,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
legacySuppressedKeys: broadcastLegacySuppressedKeys,
win32InputMode: term.modes.win32InputMode,
shiftEnterSettings: ctx.terminalSettingsRef.current,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
}),
getSessionId: () => ctx.sessionRef.current,
@@ -541,7 +541,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}), {
data: "\u001b\r",
data: "\n",
kittyEncoded: false,
urgentInterrupt: false,
});
@@ -552,7 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}), {
data: "\u001b\r",
data: "\n",
kittyEncoded: false,
urgentInterrupt: false,
});
@@ -571,61 +571,6 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
});
});
test("native kitty broadcast targets keep Shift+Enter as CSI-u when custom kitty is off", () => {
const shiftEnter = {
kind: "key" as const,
fallbackToLegacy: true,
event: {
type: "keydown" as const,
key: "Enter",
code: "Enter",
shiftKey: true,
},
};
const encodedKeys = new Set<string>();
const legacySuppressedKeys = new Set<string>();
const base = {
kittyProtocolEnabled: false,
kittyMode: createKittyKeyboardModeState(),
applicationCursorMode: false,
shiftEnterSettings: {
shiftEnterNewlineEnabled: true,
shiftEnterNewlineText: "\\e\\r",
},
};
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
...base,
encodedKeys,
legacySuppressedKeys,
nativeKittyPreservesShiftEnter: true,
}), {
data: SHIFT_ENTER_CSI_U_SEQUENCE,
kittyEncoded: true,
urgentInterrupt: false,
});
assert.equal(resolveKittyKeyboardBroadcastInput({
kind: "legacy",
data: SHIFT_ENTER_CSI_U_SEQUENCE,
keyIdentity: "Enter",
}, {
...base,
encodedKeys,
legacySuppressedKeys,
nativeKittyPreservesShiftEnter: true,
}), null);
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
...base,
encodedKeys: new Set<string>(),
nativeKittyPreservesShiftEnter: false,
}), {
data: "\u001b\r",
kittyEncoded: false,
urgentInterrupt: false,
});
});
test("Win32 broadcast targets resolve Shift+Enter from their own force-text setting", () => {
const shiftEnterRecord = "\u001b[13;28;13;1;16;1_";
const press = { type: "keydown" as const, key: "Enter", code: "Enter", shiftKey: true };
@@ -11,7 +11,6 @@ import {
isBareShiftEnterLineEnding,
resolveShiftEnterText,
resolveWin32ForcedShiftEnterText,
SHIFT_ENTER_CSI_U_SEQUENCE,
shouldSendShiftEnterText,
} from "./shiftEnterText";
@@ -254,11 +253,6 @@ type ResolveKittyKeyboardBroadcastOptions = {
TerminalSettings,
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText" | "shiftEnterForceText"
>;
/**
* Target xterm negotiated native kitty flags that encode Shift+Enter as
* CSI 13;2u, while Netcatty's custom kitty setting is off.
*/
nativeKittyPreservesShiftEnter?: boolean;
/**
* Presses this Win32 target turned into its configured Shift+Enter text
* instead of a native record. Their paired releases are dropped so ConPTY
@@ -424,18 +418,6 @@ export const resolveKittyKeyboardBroadcastInput = (
if (remapped) return remapped;
return { data: encoded, kittyEncoded: true, urgentInterrupt: false };
}
// Custom kitty is off. A target whose xterm already negotiated a preserving
// native mode must get CSI 13;2u, and the source's follow-up legacy packet
// (the same sequence) must not be written again.
if (options.nativeKittyPreservesShiftEnter && shouldSendShiftEnterText(input.event)) {
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
return {
data: SHIFT_ENTER_CSI_U_SEQUENCE,
kittyEncoded: true,
urgentInterrupt: false,
};
}
if (!input.fallbackToLegacy || input.event.type === "keyup") return null;
const legacy = encodeLegacyKeyboardEvent(input.event, options.applicationCursorMode);
if (!legacy) return null;
@@ -1,68 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import xterm from "@xterm/xterm";
import {
nativeKittyKeyboardPreservesShiftEnter,
readNativeKittyKeyboardFlags,
SHIFT_ENTER_CSI_U_SEQUENCE,
} from "./shiftEnterText.ts";
const { Terminal } = xterm;
type KittyProbe = {
flags: number;
shiftEnter: string | undefined;
};
const probeShiftEnter = (setup: string): Promise<KittyProbe> => {
const term = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
vtExtensions: { kittyKeyboard: true },
});
return new Promise((resolve) => {
term.write(setup, () => {
const event = {
type: "keydown",
key: "Enter",
code: "Enter",
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
repeat: false,
keyCode: 13,
};
const core = (term as unknown as {
_core: {
_keyboardService: {
evaluateKeyDown: (ev: object) => { key?: string };
};
};
})._core;
resolve({
flags: readNativeKittyKeyboardFlags(term),
shiftEnter: core._keyboardService.evaluateKeyDown(event).key,
});
});
});
};
test("xterm kitty keyboard encodes opted-in Shift+Enter as CSI 13;2u", async () => {
const idle = await probeShiftEnter("");
assert.equal(idle.flags, 0);
assert.equal(idle.shiftEnter, "\r");
assert.equal(nativeKittyKeyboardPreservesShiftEnter(idle.flags), false);
const disambiguate = await probeShiftEnter("\u001b[=1u");
assert.equal(disambiguate.flags, 1);
assert.equal(disambiguate.shiftEnter, SHIFT_ENTER_CSI_U_SEQUENCE);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(disambiguate.flags), true);
const alternateOnly = await probeShiftEnter("\u001b[=4u");
assert.equal(alternateOnly.flags, 4);
assert.equal(alternateOnly.shiftEnter, "\r");
assert.equal(nativeKittyKeyboardPreservesShiftEnter(alternateOnly.flags), false);
});
@@ -6,7 +6,6 @@ import {
decodeTerminalTextEscapes,
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
isBareShiftEnterLineEnding,
isShiftEnterLineContinuationText,
resolveShiftEnterText,
@@ -27,8 +26,8 @@ const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
...overrides,
}) as KeyboardEvent;
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
assert.equal(resolveShiftEnterText(), "\u001b\r");
test("shift enter text defaults to newline", () => {
assert.equal(resolveShiftEnterText(), "\n");
});
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
@@ -72,20 +71,6 @@ test("shift enter submitted input detects single command text with a line ending
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
});
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
text: "echo hi",
lineEnding: "\r",
});
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
text: "\u001b",
lineEnding: "\n",
});
});
test("shift enter handler only matches plain Shift+Enter keydown", () => {
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
@@ -162,41 +147,6 @@ test("force-text opt-out only overrides the Win32 hand-off", () => {
assert.equal(shouldClaimShiftEnterForText(keyEvent({ ctrlKey: true }), forced, win32), false);
});
test("native kitty Shift+Enter steps aside only for CSI-u flag sets", () => {
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0001), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0010), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b1000), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0100), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b10000), false);
const settings = { shiftEnterNewlineEnabled: true, shiftEnterForceText: false };
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
false,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: false,
}),
true,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), { ...settings, shiftEnterForceText: true }, {
win32InputMode: true,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
true,
);
});
test("Win32 forced Shift+Enter text requires the opt-out and non-empty text", () => {
const settings = {
shiftEnterNewlineEnabled: true,
@@ -229,7 +179,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
// skipping the ConPTY Win32 hand-off only when the force-text opt-out is on.
assert.match(
source,
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
);
// The gate above only decides whether Shift+Enter is *eligible* for send-text.
// It must then claim the keydown only when resolveShiftEnterText() returned
@@ -257,7 +207,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*kittyKeyboard: true,\s*\},/s,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
);
assert.match(
source,
@@ -273,7 +223,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),[\s\S]*?\}\),/s,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
);
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
+6 -36
View File
@@ -1,6 +1,6 @@
import type { TerminalSettings } from "../../../domain/models";
export const DEFAULT_SHIFT_ENTER_TEXT = "\\e\\r";
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
@@ -100,24 +100,6 @@ export function doesKittyEncodingPreserveShiftEnter(
&& !isBareShiftEnterLineEnding(encoded);
}
/**
* xterm 6.1 flag bits that encode Shift+Enter as CSI 13;2u.
* Disambiguate (1), report event types (2), and report all keys (8).
* Alternate-key (4) or associated-text (16) alone still emit a bare CR.
*/
const NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS = 0b0001 | 0b0010 | 0b1000;
export function nativeKittyKeyboardPreservesShiftEnter(flags: number): boolean {
return (flags & NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS) !== 0;
}
export function readNativeKittyKeyboardFlags(term: {
_core?: { coreService?: { kittyKeyboard?: { flags?: unknown } } };
}): number {
const flags = term._core?.coreService?.kittyKeyboard?.flags;
return typeof flags === "number" && Number.isFinite(flags) ? flags : 0;
}
export function isShiftEnterLineContinuationText(text: string): boolean {
return /\\(?:\r\n|\r|\n)$/.test(text);
}
@@ -127,15 +109,10 @@ export type ShiftEnterSubmittedInput = {
lineEnding: "\r\n" | "\r" | "\n";
};
/** ESC immediately before a terminating CR is the legacy Alt+Enter line break. */
function isAltEnterLineBreak(text: string): boolean {
return text.endsWith("\u001b\r") || text.endsWith("\u001b\r\n");
}
export function getShiftEnterSubmittedInput(
text: string,
): ShiftEnterSubmittedInput | null {
if (isShiftEnterLineContinuationText(text) || isAltEnterLineBreak(text)) return null;
if (isShiftEnterLineContinuationText(text)) return null;
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
if (!match) return null;
return {
@@ -168,8 +145,6 @@ export function resolveWin32ForcedShiftEnterText(
* opted out with shiftEnterForceText; elsewhere the text is used only when the
* negotiated Kitty encoding would collapse the chord to a bare CR/LF, so the
* Win32 opt-out can never override a Kitty encoding that preserves it.
* xterm's own CSI-u mode is the same kind of preserving encoding: step aside
* so the app receives CSI 13;2u.
*/
export function shouldClaimShiftEnterForText(
event: ShiftEnterEvent,
@@ -177,15 +152,10 @@ export function shouldClaimShiftEnterForText(
TerminalSettings,
"shiftEnterNewlineEnabled" | "shiftEnterForceText"
> | undefined,
mode: {
win32InputMode: boolean;
kittySequenceForKeyDown: string | null;
nativeKittyPreservesShiftEnter?: boolean;
},
mode: { win32InputMode: boolean; kittySequenceForKeyDown: string | null },
): boolean {
if (!shouldSendShiftEnterText(event, settings)) return false;
if (mode.win32InputMode) return settings?.shiftEnterForceText === true;
if (doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown)) return false;
if (mode.nativeKittyPreservesShiftEnter) return false;
return true;
return mode.win32InputMode
? settings?.shiftEnterForceText === true
: !doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown);
}
-5
View File
@@ -448,11 +448,6 @@ export function shouldClaimDisconnectedDialogFocus({
// Sink already focused.
return active !== dialogNode;
}
// An open compose draft owns Enter: empty reconnects from the bar, and a
// non-empty draft must keep send behavior (#3767). Do not park focus here.
if (active.closest("[data-section='terminal-compose-bar']")) {
return false;
}
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
if (terminalRoot?.contains(active)) {
return true;
-19
View File
@@ -98,7 +98,6 @@ test("disconnected dialog focus claim never steals from other panes", () => {
id: string;
parent?: FakeNode | null;
interactive?: boolean;
composeBar?: boolean;
children?: FakeNode[];
},
) {
@@ -119,7 +118,6 @@ test("disconnected dialog focus claim never steals from other panes", () => {
return false;
}
closest(selector: string) {
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
if (selector.includes("button") && this.opts.interactive) return this;
return null;
}
@@ -229,23 +227,6 @@ test("disconnected dialog focus claim never steals from other panes", () => {
}),
false,
);
// Compose-bar focus stays put so an empty draft can reconnect and a
// non-empty draft is not stolen by the disconnected dialog (#3767).
const composeInput = new FakeNode({ id: "compose", composeBar: true });
const composeDialog = new FakeNode({ id: "compose-dialog" });
const composeSession = new FakeNode({
id: "compose-session",
children: [composeInput, composeDialog],
});
assert.equal(
shouldClaimDisconnectedDialogFocus({
activeElement: composeInput as unknown as Element,
dialogNode: composeDialog as unknown as HTMLElement,
sessionRoot: composeSession as unknown as Element,
documentBody: body as unknown as Element,
}),
false,
);
assert.equal(
restoreTerminalFocusFromDisconnectedDialog({
+76 -43
View File
@@ -30,13 +30,6 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
import {
TOP_TAB_ITEM_CLASS,
clearInactiveTopTabHover,
paintInactiveTopTabHover,
resolveTopTabChromeStyle,
topTabActiveValue,
} from './topTabActiveStyle';
import { renderHostIconGlyph } from '../hostIconRenderer';
import type { PluginViewTab } from '../../application/state/pluginViewTabStore';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
@@ -317,19 +310,13 @@ export const WindowControls: React.FC = memo(() => {
const controlClassName = 'window-control-btn app-no-drag';
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
// Mouse press would move DOM focus onto minimize/maximize. After restore
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
// Close skips this so the active field still blurs (inline rename commits on blur).
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault();
};
return (
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
<button type="button" className={controlClassName} onClick={handleMinimize}>
<Minus size={16} />
</button>
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
<button type="button" className={controlClassName} onClick={handleMaximize}>
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
</button>
<button type="button" className={closeControlClassName} onClick={handleClose}>
@@ -453,20 +440,31 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
data-tab-id={tabId}
data-tab-type="root"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
compact ? "px-2 gap-0" : "px-3 gap-2",
className,
)}
style={resolveTopTabChromeStyle(isActive, { suppressActiveBg })}
style={{
backgroundColor: isActive && !suppressActiveBg
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
@@ -525,7 +523,6 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
data-tab-id={tab.id}
data-tab-type="plugin-view"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={() => activeTabStore.setActiveTabId(tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
@@ -536,7 +533,6 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(event) => onTabDrop(event, tab.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag',
'flex transition-transform duration-150',
isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40',
@@ -544,7 +540,8 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
)}
style={{
...shiftStyle,
...resolveTopTabChromeStyle(isActive),
backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent',
color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
>
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
@@ -646,7 +643,6 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-tab-id={tabId}
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
@@ -657,7 +653,6 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, tabId)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -665,13 +660,24 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
)}
style={{
...shiftStyle,
...resolveTopTabChromeStyle(isActive),
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -821,7 +827,6 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-tab-id={session.id}
data-tab-type="session"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
@@ -833,7 +838,6 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, session.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -841,13 +845,24 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
)}
style={{
...shiftStyle,
...resolveTopTabChromeStyle(isActive),
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -1024,7 +1039,6 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -1037,7 +1051,6 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, workspace.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -1046,13 +1059,24 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
)}
style={{
...shiftStyle,
...resolveTopTabChromeStyle(isActive),
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -1173,7 +1197,6 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-tab-id={logView.id}
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
@@ -1184,7 +1207,6 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, logView.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -1192,13 +1214,24 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
)}
style={{
...shiftStyle,
...resolveTopTabChromeStyle(isActive),
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
-73
View File
@@ -1,73 +0,0 @@
/**
* Stable hooks for the selected top tab.
*
* Defaults fall through to the existing chrome colors, so leaving the custom
* properties unset keeps today's look. Custom CSS can set them on
* `.top-tab[data-active="true"]` (or any ancestor) without !important.
* `--top-tab-active-border` is read by index.css as a 1px inset outline and
* defaults to transparent.
*/
export const TOP_TAB_ITEM_CLASS = 'top-tab';
/** Previous selected fill, kept as the fallback when the hook is unset. */
export const TOP_TAB_LEGACY_ACTIVE_BG = 'var(--top-tabs-active-bg, hsl(var(--background)))';
/** Previous selected label color, kept as the fallback when the hook is unset. */
export const TOP_TAB_LEGACY_ACTIVE_FG = 'var(--top-tabs-fg, hsl(var(--foreground)))';
export const TOP_TAB_ACTIVE_BG = `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`;
export const TOP_TAB_ACTIVE_FG = `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`;
export const TOP_TAB_ACTIVE_BORDER = 'var(--top-tab-active-border, transparent)';
export const TOP_TAB_INACTIVE_FG = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
/** Same 40% mix as the previous hover fill. The inner color is the active-tab hook. */
export const TOP_TAB_HOVER_BG = `color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`;
export const TOP_TAB_ACTIVE_OUTLINE = `1px solid ${TOP_TAB_ACTIVE_BORDER}`;
export function topTabActiveValue(isActive: boolean): 'true' | 'false' {
return isActive ? 'true' : 'false';
}
export interface TopTabChromeStyle {
backgroundColor: string;
color: string;
}
export function resolveTopTabChromeStyle(
isActive: boolean,
options?: { suppressActiveBg?: boolean },
): TopTabChromeStyle {
if (!isActive) {
return {
backgroundColor: 'transparent',
color: TOP_TAB_INACTIVE_FG,
};
}
return {
backgroundColor: options?.suppressActiveBg ? 'transparent' : TOP_TAB_ACTIVE_BG,
color: TOP_TAB_ACTIVE_FG,
};
}
type TopTabStyleTarget = {
style: {
backgroundColor: string;
color: string;
};
};
export function paintInactiveTopTabHover(element: TopTabStyleTarget): void {
element.style.backgroundColor = TOP_TAB_HOVER_BG;
element.style.color = TOP_TAB_ACTIVE_FG;
}
export function clearInactiveTopTabHover(element: TopTabStyleTarget): void {
element.style.backgroundColor = 'transparent';
element.style.color = TOP_TAB_INACTIVE_FG;
}
-172
View File
@@ -1,172 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { JSDOM } from 'jsdom';
import en from '../application/i18n/locales/en';
import es from '../application/i18n/locales/es';
import ru from '../application/i18n/locales/ru';
import zhCN from '../application/i18n/locales/zh-CN';
import zhTW from '../application/i18n/locales/zh-TW';
import {
TOP_TAB_ACTIVE_BG,
TOP_TAB_ACTIVE_BORDER,
TOP_TAB_ACTIVE_FG,
TOP_TAB_ACTIVE_OUTLINE,
TOP_TAB_HOVER_BG,
TOP_TAB_INACTIVE_FG,
TOP_TAB_ITEM_CLASS,
TOP_TAB_LEGACY_ACTIVE_BG,
TOP_TAB_LEGACY_ACTIVE_FG,
clearInactiveTopTabHover,
paintInactiveTopTabHover,
resolveTopTabChromeStyle,
topTabActiveValue,
} from './top-tabs/topTabActiveStyle';
const topTabItemsSource = readFileSync(
fileURLToPath(new URL('./top-tabs/TopTabItems.tsx', import.meta.url)),
'utf8',
);
const indexCss = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8');
const TAB_TYPES = ['root', 'plugin-view', 'editor', 'session', 'workspace', 'logView'] as const;
const LOCALES = [
{ name: 'en', messages: en },
{ name: 'zh-CN', messages: zhCN },
{ name: 'zh-TW', messages: zhTW },
{ name: 'es', messages: es },
{ name: 'ru', messages: ru },
];
const HOOKS = ['--top-tab-active-bg', '--top-tab-active-fg', '--top-tab-active-border'] as const;
test('active top tab colors fall back to the current chrome colors', () => {
assert.equal(TOP_TAB_ACTIVE_BG, `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`);
assert.equal(TOP_TAB_ACTIVE_FG, `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`);
assert.equal(TOP_TAB_ACTIVE_BORDER, 'var(--top-tab-active-border, transparent)');
assert.equal(TOP_TAB_LEGACY_ACTIVE_BG, 'var(--top-tabs-active-bg, hsl(var(--background)))');
assert.equal(TOP_TAB_LEGACY_ACTIVE_FG, 'var(--top-tabs-fg, hsl(var(--foreground)))');
assert.equal(
TOP_TAB_HOVER_BG,
`color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`,
);
const active = resolveTopTabChromeStyle(true);
assert.deepEqual(active, {
backgroundColor: TOP_TAB_ACTIVE_BG,
color: TOP_TAB_ACTIVE_FG,
});
const inactive = resolveTopTabChromeStyle(false);
assert.deepEqual(inactive, {
backgroundColor: 'transparent',
color: TOP_TAB_INACTIVE_FG,
});
assert.equal(TOP_TAB_INACTIVE_FG, 'var(--top-tabs-muted, hsl(var(--muted-foreground)))');
const vault = resolveTopTabChromeStyle(true, { suppressActiveBg: true });
assert.equal(vault.backgroundColor, 'transparent');
assert.equal(vault.color, TOP_TAB_ACTIVE_FG);
});
test('top tab active attribute is a stable true/false hook', () => {
assert.equal(topTabActiveValue(true), 'true');
assert.equal(topTabActiveValue(false), 'false');
assert.equal(TOP_TAB_ITEM_CLASS, 'top-tab');
});
test('inactive hover uses the active-tab hook and clears back to the flat tab', () => {
const element = { style: { backgroundColor: '', color: '' } };
paintInactiveTopTabHover(element);
assert.equal(element.style.backgroundColor, TOP_TAB_HOVER_BG);
assert.equal(element.style.color, TOP_TAB_ACTIVE_FG);
clearInactiveTopTabHover(element);
assert.equal(element.style.backgroundColor, 'transparent');
assert.equal(element.style.color, TOP_TAB_INACTIVE_FG);
});
test('every top tab kind exposes the active style hook', () => {
for (const type of TAB_TYPES) {
const marker = `data-tab-type="${type}"`;
const start = topTabItemsSource.indexOf(marker);
assert.notEqual(start, -1, marker);
const next = topTabItemsSource.indexOf('data-tab-type=', start + marker.length);
const slice = topTabItemsSource.slice(start, next === -1 ? undefined : next);
assert.match(slice, /data-active=\{topTabActiveValue\(isActive\)\}/);
assert.match(slice, /TOP_TAB_ITEM_CLASS/);
assert.match(slice, /resolveTopTabChromeStyle\(isActive/);
}
assert.equal(topTabItemsSource.split('data-active={topTabActiveValue(isActive)}').length - 1, TAB_TYPES.length);
assert.doesNotMatch(topTabItemsSource, /backgroundColor: isActive/);
});
test('selected top tab border hook defaults to a transparent inset outline', () => {
const start = indexCss.indexOf('[data-section="top-tabs"] .top-tab[data-active="true"]');
assert.notEqual(start, -1);
const rule = indexCss.slice(start, indexCss.indexOf('}', start));
assert.match(rule, /outline:\s*1px solid var\(--top-tab-active-border, transparent\)/);
assert.match(rule, /outline-offset:\s*-1px/);
assert.equal(TOP_TAB_ACTIVE_OUTLINE, '1px solid var(--top-tab-active-border, transparent)');
});
test('custom CSS sets the top tab hooks on the selected tab', () => {
const dom = new JSDOM(`<!doctype html><html><head></head><body>
<div data-section="top-tabs">
<div id="active" class="top-tab" data-active="true"></div>
<div id="idle" class="top-tab" data-active="false"></div>
</div>
</body></html>`);
const { document, getComputedStyle } = dom.window;
const active = document.getElementById('active')!;
const idle = document.getElementById('idle')!;
const activeStyle = resolveTopTabChromeStyle(true);
active.style.backgroundColor = activeStyle.backgroundColor;
active.style.color = activeStyle.color;
Object.assign(idle.style, resolveTopTabChromeStyle(false));
assert.equal(active.getAttribute('data-active'), 'true');
assert.equal(active.style.backgroundColor, TOP_TAB_ACTIVE_BG);
assert.equal(active.style.color, TOP_TAB_ACTIVE_FG);
assert.equal(idle.style.backgroundColor, 'transparent');
assert.equal(getComputedStyle(active).getPropertyValue('--top-tab-active-bg').trim(), '');
const custom = document.createElement('style');
custom.id = 'netcatty-custom-css';
custom.textContent = `
.top-tab[data-active="true"] {
--top-tab-active-bg: rgb(37, 99, 235);
--top-tab-active-fg: rgb(255, 255, 255);
--top-tab-active-border: rgb(29, 78, 216);
}
`;
document.head.appendChild(custom);
const styled = getComputedStyle(active);
const normalized = (value: string) => value.replace(/\s+/g, '');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-bg')), 'rgb(37,99,235)');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-fg')), 'rgb(255,255,255)');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-border')), 'rgb(29,78,216)');
assert.equal(getComputedStyle(idle).getPropertyValue('--top-tab-active-bg').trim(), '');
assert.match(active.style.backgroundColor, /var\(--top-tab-active-bg,/);
});
test('custom CSS help mentions the top tab hooks in every locale', () => {
for (const locale of LOCALES) {
const desc = locale.messages['settings.appearance.customCss.desc'];
const help = locale.messages['settings.appearance.customCss.help.body'];
const placeholder = locale.messages['settings.appearance.customCss.placeholder'];
assert.equal(typeof desc, 'string', locale.name);
assert.equal(typeof help, 'string', locale.name);
assert.equal(typeof placeholder, 'string', locale.name);
for (const hook of HOOKS) {
assert.ok(desc.includes(hook), `${locale.name} description missing ${hook}`);
assert.ok(help.includes(hook), `${locale.name} help missing ${hook}`);
assert.ok(placeholder.includes(hook), `${locale.name} placeholder missing ${hook}`);
}
assert.match(help, /\.top-tab\[data-active="true"\]/);
assert.match(placeholder, /\.top-tab\[data-active="true"\]/);
}
});
+1 -12
View File
@@ -503,17 +503,6 @@ export const normalizeTerminalSettings = (
/** Default scrollback rows for new installs (VS Code uses 1000; we keep a modest headroom). */
export const DEFAULT_TERMINAL_SCROLLBACK = 3000;
/** Backslash-escaped Shift+Enter text for a fresh install (legacy Alt+Enter). */
export const DEFAULT_SHIFT_ENTER_NEWLINE_TEXT = '\\e\\r';
/**
* Stored forms of the pre-#3770 Shift+Enter default. `\\n` is the escaped
* setting string; `\n` is a raw newline if one was ever persisted.
*/
export function isLegacyDefaultShiftEnterNewlineText(value: unknown): boolean {
return value === '\\n' || value === '\n';
}
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
drawBoldInBrightColors: true,
@@ -533,7 +522,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
altAsMeta: false,
optionArrowWordJump: false,
shiftEnterNewlineEnabled: true,
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
shiftEnterNewlineText: '\\n',
shiftEnterForceText: false,
kittyKeyboardProtocolEnabled: false,
scrollOnInput: true,
+2 -13
View File
@@ -93,7 +93,7 @@ test("normalizeTerminalSettings enables Shift+Enter newline by default", () => {
const settings = normalizeTerminalSettings();
assert.equal(settings.shiftEnterNewlineEnabled, true);
assert.equal(settings.shiftEnterNewlineText, "\\e\\r");
assert.equal(settings.shiftEnterNewlineText, "\\n");
});
test("normalizeTerminalSettings disables Kitty keyboard protocol by default", () => {
@@ -111,21 +111,10 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
);
});
test("normalizeTerminalSettings keeps an explicit legacy Shift+Enter newline", () => {
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: "\\n" }).shiftEnterNewlineText,
"\\n",
);
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: "\n" }).shiftEnterNewlineText,
"\n",
);
});
test("normalizeTerminalSettings falls back when Shift+Enter text is not a string", () => {
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: null as never }).shiftEnterNewlineText,
"\\e\\r",
"\\n",
);
});
-9
View File
@@ -819,15 +819,6 @@ html[data-theme-transition] *::after {
-webkit-user-select: auto;
}
/* Selected top-tab hooks. Fill and text stay on the tab's inline style so the
unset custom properties fall through to the existing chrome colors. The
border is a 1px inset outline and stays invisible until
--top-tab-active-border is set (Settings → Appearance → Custom CSS). */
[data-section="top-tabs"] .top-tab[data-active="true"] {
outline: 1px solid var(--top-tab-active-border, transparent);
outline-offset: -1px;
}
/* Window controls: icons sit in the h-7 tab row; hover fills the full h-9 title bar */
[data-section="top-tabs"] .window-control-btn {
position: relative;
@@ -363,56 +363,3 @@ test('prepareTurnContext calls summarize when over dynamic threshold', async ()
assert.equal(prepared.trace?.didLlmSummarize, true);
assert.match(JSON.stringify(prepared.messages), /summary of earlier work/);
});
test('normal turn and step preparation keep user attachment parts', async () => {
const messages: ModelMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'what does this image say?' },
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
],
},
];
const turn = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 1_300_000,
trigger: 'pre-turn',
});
const step = await prepareStepContext({
messages: turn.messages,
contextWindow: 1_300_000,
stepNumber: 0,
});
for (const prepared of [turn, step]) {
const content = prepared.messages.find(message => message.role === 'user')?.content;
assert.ok(Array.isArray(content));
assert.equal(content.some(part => (part as { type?: string }).type === 'file'), true);
assert.doesNotMatch(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
}
});
test('413 retry still trades attachments for a text placeholder', async () => {
const messages: ModelMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'what does this image say?' },
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
],
},
];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 1_300_000,
trigger: '413-retry',
compressForRequestTooLargeRetry: true,
});
assert.match(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
});
+5 -15
View File
@@ -7,10 +7,7 @@ import {
keepRecentContextMessages,
prepareContextCompaction,
} from '../contextCompaction';
import {
compressMessagesForRequestTooLargeRetry,
type CompressMessagesForRequestTooLargeRetryOptions,
} from '../requestPayloadCompression';
import { compressMessagesForRequestTooLargeRetry } from '../requestPayloadCompression';
import {
computeCompactionThreshold,
computeTotalInputTokens,
@@ -99,14 +96,11 @@ function emitCompactionStart(
});
}
function applyTypedMessageCompression(
messages: ModelMessage[],
options?: CompressMessagesForRequestTooLargeRetryOptions,
): {
function applyTypedMessageCompression(messages: ModelMessage[]): {
messages: ModelMessage[];
didAdjust: boolean;
} {
const compressed = compressMessagesForRequestTooLargeRetry(messages, options);
const compressed = compressMessagesForRequestTooLargeRetry(messages);
return { messages: compressed.messages, didAdjust: compressed.didAdjust };
}
@@ -231,9 +225,7 @@ export async function prepareTurnContext(
did413Fallback = typed.didAdjust;
didAdjust = didAdjust || typed.didAdjust;
} else {
// Routine pre-turn pass: keep the user's attachment parts. Only the explicit
// 413 retry above may trade them for text placeholders.
const typed = applyTypedMessageCompression(working, { preserveAttachments: true });
const typed = applyTypedMessageCompression(working);
if (typed.didAdjust) {
working = typed.messages;
didTypedCompression = true;
@@ -420,9 +412,7 @@ export async function prepareStepContext(
});
let working = stale.messages;
// Step-level typed compression runs before every model call, so it must keep
// multimodal parts: the user's image would otherwise never reach the model.
const typed = compressMessagesForRequestTooLargeRetry(working, { preserveAttachments: true });
const typed = compressMessagesForRequestTooLargeRetry(working);
working = typed.messages;
const budgetGuard = applyStepBudgetGuard(working, {
@@ -90,77 +90,3 @@ test("compressMessagesForRequestTooLargeRetry reports no adjustment for compact
assert.equal(result.didAdjust, false);
assert.deepEqual(result.messages, messages);
});
test("compressMessagesForRequestTooLargeRetry keeps attachments when preserveAttachments is set", () => {
const imageData = "A".repeat(300_000);
const fileData = Buffer.from("label,hostname\nprod,prod.example.com\n").toString("base64");
const messages: ModelMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "what does this screenshot say?" },
{ type: "file", data: imageData, mediaType: "image/png" },
],
},
{
role: "user",
content: [
{ type: "text", text: "import these hosts" },
{ type: "file", data: fileData, mediaType: "text/csv", filename: "hosts.csv" },
],
},
{
role: "tool",
content: [{
type: "tool-result",
toolCallId: "call-1",
toolName: "terminal_execute",
output: { type: "text", value: "X".repeat(200_000) },
}],
},
];
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
// Text and tool output are still reduced. Images stay; other files become placeholders.
assert.equal(result.didAdjust, true);
const imageContent = result.messages[0].content;
assert.ok(Array.isArray(imageContent));
assert.deepEqual(imageContent[1], messages[0].content[1]);
assert.doesNotMatch(JSON.stringify(imageContent), /attachment omitted to keep the AI request small/);
const fileContent = result.messages[1].content;
assert.ok(Array.isArray(fileContent));
assert.deepEqual(fileContent[1], {
type: "text",
text: `[file attachment omitted to keep the AI request small: filename=hosts.csv, mediaType=text/csv, ${fileData.length} chars]`,
});
const toolContent = result.messages[2].content;
assert.ok(Array.isArray(toolContent));
const toolPart = toolContent[0] as { output?: { value?: string } };
assert.ok((toolPart.output?.value?.length ?? 0) < 5_000);
});
test("compressMessagesForRequestTooLargeRetry still placeholders text/plain files when preserveAttachments is set", () => {
const text = "hello from a text file";
const messages: ModelMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "read this note" },
{ type: "file", data: text, mediaType: "text/plain", filename: "note.txt" },
],
},
];
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
const content = result.messages[0].content;
assert.ok(Array.isArray(content));
assert.deepEqual(content[0], { type: "text", text: "read this note" });
assert.deepEqual(content[1], {
type: "text",
text: `[file attachment omitted to keep the AI request small: filename=note.txt, mediaType=text/plain, ${text.length} chars]`,
});
});
+9 -32
View File
@@ -16,18 +16,6 @@ export interface CompressMessagesForRequestTooLargeRetryResult {
didAdjust: boolean;
}
export interface CompressMessagesForRequestTooLargeRetryOptions {
/**
* Keep image attachments (`image` parts and `file` parts whose media type
* starts with `image/`) instead of replacing them with a text placeholder.
*
* Other file parts stay placeholders. Chat providers reject types such as
* `text/plain`, and the model reads those through attachment tools.
* Only the 413 retry drops images as well.
*/
preserveAttachments?: boolean;
}
/**
* Collapse noisy terminal/build output.
* Keeps semantics while removing repeated blank lines and long duplicate runs.
@@ -120,12 +108,10 @@ export function truncateTextWithHeadAndTail(
export function compressMessagesForRequestTooLargeRetry(
messages: ModelMessage[],
options: CompressMessagesForRequestTooLargeRetryOptions = {},
): CompressMessagesForRequestTooLargeRetryResult {
const preserveAttachments = options.preserveAttachments === true;
let didAdjust = false;
const compressedMessages = messages.map((message) => {
const compressed = compressModelMessageForRequestRetry(message, preserveAttachments);
const compressed = compressModelMessageForRequestRetry(message);
if (compressed !== message) didAdjust = true;
return compressed;
});
@@ -136,10 +122,7 @@ export function compressMessagesForRequestTooLargeRetry(
};
}
function compressModelMessageForRequestRetry(
message: ModelMessage,
preserveAttachments: boolean,
): ModelMessage {
function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessage {
if (typeof message.content === "string") {
// User text is an instruction or exact input, not terminal output. Lossy
// cleanup can alter commands even when the request is far below its limit.
@@ -153,7 +136,7 @@ function compressModelMessageForRequestRetry(
let didAdjust = false;
const content = message.content.map((part) => {
if (message.role === "user" && part.type === "text") return part;
const compressed = compressContentPartForRequestRetry(part, preserveAttachments);
const compressed = compressContentPartForRequestRetry(part);
if (compressed !== part) didAdjust = true;
return compressed;
});
@@ -161,7 +144,7 @@ function compressModelMessageForRequestRetry(
return didAdjust ? ({ ...message, content } as ModelMessage) : message;
}
function compressContentPartForRequestRetry(part: unknown, preserveAttachments: boolean): unknown {
function compressContentPartForRequestRetry(part: unknown): unknown {
if (!part || typeof part !== "object") return part;
const record = part as Record<string, unknown>;
const type = record.type;
@@ -195,18 +178,12 @@ function compressContentPartForRequestRetry(part: unknown, preserveAttachments:
}
}
const mediaType = typeof record.mediaType === "string" ? record.mediaType : "";
const preserveImage = preserveAttachments && (
type === "image" || (type === "file" && mediaType.startsWith("image/"))
);
if (!preserveImage) {
if (type === "image" && typeof record.image === "string") {
return omittedAttachmentTextPart("image", record.image, record);
}
if (type === "image" && typeof record.image === "string") {
return omittedAttachmentTextPart("image", record.image, record);
}
if (type === "file" && typeof record.data === "string") {
return omittedAttachmentTextPart("file", record.data, record);
}
if (type === "file" && typeof record.data === "string") {
return omittedAttachmentTextPart("file", record.data, record);
}
return part;
-2
View File
@@ -26,8 +26,6 @@ export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
/** One-time marker: pre-#3770 builds materialized shiftEnterNewlineText "\\n" into saved settings. */
export const STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED = 'netcatty_term_shift_enter_newline_migrated_v1';
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
+1 -1
View File
@@ -42,7 +42,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && node scripts/patch-xterm-image-tile-stride.cjs && node scripts/patch-xterm-image-iip-type.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && node scripts/patch-xterm-image-tile-stride.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSION = "0.10.0-beta.292";
// External IIP metadata must not overwrite fields.type, the parser's internal
// SequenceType discriminator. In particular, nu_plugin_charton emits type=image/png
// and otherwise skips FILE decoder initialization (Netcatty #3700).
// Pinned-version workaround pending an upstream release; patch both runtime bundles.
const PATCHES = [
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "this._key=t,this.fields[t]=null,!0",
replacement: 'this._key=t,t!=="type"&&(this.fields[t]=null),!0',
})),
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "_storeValue(e){if(this._key){try{",
replacement: '_storeValue(e){if(this._key){if(this._key==="type")return!0;try{',
})),
];
function patchImageSource(source, patch) {
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(patch.replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
throw new Error(
`${patch.file}: unexpected image-iip-type patch shape ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
function patchInstalledImage(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm/addon-image");
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
if (version !== EXPECTED_VERSION) {
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
}
// Validate both complete bundles before writing either one.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchImageSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledImage();
console.log(`[patch-xterm-image-iip-type] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-image-iip-type] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
-130
View File
@@ -1,130 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { pathToFileURL } = require("node:url");
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-iip-type.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchImageSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
assert.throws(() => patchImageSource(source, patch), /unexpected image-iip-type patch shape/);
}
});
}
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-iip-type-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = path.join(root, "node_modules/@xterm/addon-image");
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
for (const file of new Set(PATCHES.map(p => p.file))) {
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
}
return { root, base };
}
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
const { root, base } = fixture(t);
const cache = path.join(root, "node_modules/.vite");
fs.mkdirSync(cache);
assert.deepEqual(patchInstalledImage(root), { changed: 4, checked: 4 });
assert.equal(fs.existsSync(cache), false);
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 4 });
});
test("install fails closed on a changed version or bundle before writing anything", t => {
const { root, base } = fixture(t);
const cjs = path.join(base, "lib/addon-image.js");
const original = fs.readFileSync(cjs, "utf8");
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
assert.throws(() => patchInstalledImage(root), /unexpected image-iip-type patch shape/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
});
// Use the shipped handler and WASM decoder, with only browser image decoding and
// storage mocked. The desktop smoke covers the actual bitmap rendering.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==", "base64");
function setup(ImageAddon) {
const event = () => ({ dispose() {} });
const term = {
cols: 80, rows: 24,
_core: {
open() {},
_coreBrowserService: { window: { document: {} } },
optionsService: { onOptionChange: event },
_inputHandler: { onRequestReset: event, _parser: { registerOscHandler: event } },
},
parser: { registerCsiHandler: event, registerEscHandler: event },
buffer: { active: { type: "normal" }, onBufferChange: event },
onRender: event, onResize: event,
};
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, kittySupport: false });
addon.activate(term);
const images = [];
addon._storage.addImage = image => images.push(image);
return { addon, handler: addon._handlers.get("iip"), images };
}
async function send(handler, data, chunkSize) {
const codes = Uint32Array.from(data, c => c.charCodeAt(0));
handler.start();
for (let start = 0; start < codes.length; start += chunkSize) {
handler.put(codes, start, Math.min(start + chunkSize, codes.length));
}
await handler.end(true);
}
for (const format of ["js", "mjs"]) {
test(`${format}: type metadata preserves FILE and multipart decoding, whole and bytewise`, async t => {
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
const { ImageAddon } = format === "js" ? require(file) : await import(pathToFileURL(file).href);
const originalBitmap = Object.getOwnPropertyDescriptor(globalThis, "createImageBitmap");
globalThis.createImageBitmap = async (blob, options) => ({ blob, ...options });
t.after(() => {
if (originalBitmap) Object.defineProperty(globalThis, "createImageBitmap", originalBitmap);
else delete globalThis.createImageBitmap;
});
for (const chunkSize of [4096, 1]) {
const { addon, handler, images } = setup(ImageAddon);
const body = PNG.toString("base64");
const headers = [
"inline=1", // unchanged standard header
"inline=1;type=image/png", // reporter's final metadata field
"type=image/png;inline=1", // do not prevent subsequent known fields
"inline=1;type=application/octet-stream", // no MIME hardcoding
];
for (const header of headers) {
const count = images.length;
await send(handler, `File=size=${PNG.length};${header}:${body}`, chunkSize);
assert.equal(images.length, count + 1, header);
assert.equal(handler._hp.fields.type, 1);
assert.equal(images.at(-1).blob.type, "image/png", "image type still comes from payload bytes");
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
}
const count = images.length;
await send(handler, `MultipartFile=inline=1;size=${PNG.length};type=image/png`, chunkSize);
assert.equal(handler._hp.fields.type, 2, "final type value must not break HeaderParser.end()");
await send(handler, `FilePart=${body}`, chunkSize);
await send(handler, "FileEnd", chunkSize);
assert.equal(images.length, count + 1);
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
await send(handler, `File=inline=0;type=image/png:${body}`, chunkSize);
assert.equal(images.length, count + 1, "non-inline files stay suppressed");
addon.dispose();
}
});
}