mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c300595fb8 | ||
|
|
d5395abc38 | ||
|
|
ade3f0940a |
No files matched your search
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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), которые не читают модификаторы из записей ввода консоли.',
|
||||
|
||||
@@ -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': '界面字体',
|
||||
|
||||
@@ -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。',
|
||||
|
||||
@@ -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': '介面字型',
|
||||
|
||||
@@ -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,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', () => {});
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"\]/);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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]`,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user