Compare commits

...

12 Commits

Author SHA1 Message Date
陈大猫
6e6a0240a7 Merge pull request #1295 from binaricat/codex/fix-issue-1293
Some checks failed
build-packages / bump homebrew tap (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
2026-06-08 16:49:01 +08:00
bincxz
2e2360a9fc test(terminal): cover Kylin screenshot prompts and fix sudo fast path
Add issue #1293 screenshot-exact cases for sudo/telnet autofill and
include English password in the handleOutput fast-path bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:29:25 +08:00
陈奇
8011f4e2e8 fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
Kylin Professional's sudo prompt doesn't include the [sudo] tag and
doesn't end with a colon. The existing regex patterns required either
[sudo] or a trailing colon, causing the autofill hint to never fire.

Changes:
- Make trailing colon optional in SUDO_PROMPT_PATTERN and
  EXPLICIT_SUDO_PROMPT_PATTERN (terminalSudoAutofill.ts)
- Update fast path to not skip output containing Chinese password
  keywords (密码/口令) that lack a colon
- Make trailing colon/angle-bracket optional in telnet username and
  password prompt patterns (telnetAutoLogin.cjs)
- Also relax LAST_LOGIN_PATTERN for consistency
- Add Kylin-style test cases for both sudo and telnet auto-login
2026-06-08 16:04:20 +08:00
陈大猫
970037682c feat: add adjustable window opacity for overlay use (#1304) (#1309)
Expose whole-window transparency via setOpacity with settings and a top-bar quick control, persisting across restarts and syncing across windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:57:25 +08:00
陈大猫
42b58efc5c fix: align top bar right-side icon buttons (#1298) (#1303)
* fix: align top bar right-side icon buttons (#1298)

Unify AI/sync/theme/settings buttons to h-7 in one row aligned with tabs.
SyncStatusButton was h-8 and settings lived in a separate container, causing
misalignment. Preserve Windows spacing before window controls (mr-2).

Fixes #1298

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: align window controls with utility icons in top bar

Merge window controls into the same row as utility buttons, match h-7 height, add left margin separation, and restore rectangular gray hover for all three buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:07:07 +08:00
陈大猫
b20163d762 fix: add custom CSS hooks for terminal side panel and split view (#1302)
Expose data-section selectors for SFTP/side panel, split panes, and resizers
so custom CSS can target the correct regions. Clarify docs that
terminal-workspace-sidebar is focus-mode only.

Fixes #1301

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:11:51 +08:00
陈大猫
e0a56cbb14 fix(ai): infer MIME type from file extension for YAML and other code files (#1289)
* fix(ai): infer MIME type from file extension for YAML and other code files

When uploading YAML files (and other code/text files) via Electron,
file.type is often empty, causing the system to default to
'application/octet-stream'. AI providers reject this media type
with 'functionality not supported'.

Fix by inferring the correct MIME type from the file extension
when file.type is empty. Includes mappings for YAML, JSON, TOML,
shell scripts, and 50+ common code/text file extensions.

Fixes #1287

* fix(ai): use text/plain for all code/text files to ensure provider compatibility

Change all non-standard MIME types (text/x-*, application/x-*) to
text/plain for maximum provider compatibility. Anthropic and other
providers reject non-standard MIME types like application/x-yaml
with 'UnsupportedFunctionalityError'.

Changes:
- All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain
- Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain
- yaml/yml → text/plain (was application/x-yaml)
- dockerfile → text/plain (was text/x-dockerfile)
- Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
2026-06-07 19:05:33 +08:00
陈大猫
8dae851ea3 fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286) (#1288)
* fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286)

Two bugs in prompt detection for Chinese-locale users:

1. telnetAutoLogin: USERNAME_PROMPT_PATTERN, PASSWORD_PROMPT_PATTERN,
   and LAST_LOGIN_PATTERN only matched half-width colon ':' or '>'.
   Chinese-locale telnet prompts use full-width colon ':' (U+FF1A),
   e.g. '登录:', '密码:'. Changed [:'>] to [::'|] in all three
   patterns to accept both colon variants.

2. terminalSudoAutofill: EXPLICIT_SUDO_PROMPT_PATTERN required
   '[sudo]' (closing bracket immediately after 'sudo'), but Chinese
   sudo prompts use '[sudo: authenticate] 密码:' format where sudo
   is followed by colon. Changed \[sudo\] to \[sudo[^\]]*\] to
   match any '[sudo...]' variant, making the explicit (no-arm-needed)
   hint detection work for Chinese locale.

Fixes #1286

* fix: restore OSC stripping pattern broken in previous commit

The regex negated character class [^\x07]* was truncated to just \x07,
breaking OSC sequence stripping (e.g. window title changes embedded in
terminal output). Restore the original negated class so stripTerminalControl-
Sequences continues to remove OSC title sequences before prompt detection.

This was caught by Codex review of PR #1288.
2026-06-07 19:05:27 +08:00
bincxz
03ba9595c0 fix(terminal): hint reliably on explicit [sudo] prompts (#1284)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Sudo hints were flaky for manually typed commands: arming depends on
recognizing the submitted line as a command (recordedCommand), which is
unreliable while echo round-trips over SSH — so the hint sometimes didn't
fire for "sudo -i" / "sudo -s".

Explicit "[sudo] …" prompts are sudo-specific, so hint on them without
requiring an arm — reliable regardless of command recording. Bare
"Password:" still needs the arm window to avoid noise on unrelated prompts
(ssh, mysql). Filling still requires explicit Enter, so showing the hint
without arming stays safe. Added a colon fast-path so bulk output skips the
detection regex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:47:08 +08:00
bincxz
4b07b4826a fix(terminal): resolve sudo autofill password through identity references (#1284)
Sudo autofill only read host.password and never resolved a host's reference
to a Keychain identity (host.identityId). When the account password lived in
a referenced identity, the autofill got nothing — while SSH login worked
because it goes through resolveHostAuth, which resolves the identity.

Add domain resolveHostAutofillPassword (same resolveHostAuth resolution:
identity.password ?? host.password, honoring savePassword and dropping
undecryptable placeholders) and use it as the terminal autofill password
source. Login and autofill now share one resolution path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:21:54 +08:00
陈大猫
80d9b33c59 feat(terminal): confirm-to-fill sudo password hint (#1281)
Rework sudo password autofill from auto-fill to a Tabby-style hint + Enter to confirm. When a sudo command is armed and a password prompt appears, show a dimmed inline hint instead of sending the password; Enter pastes the saved password and submits, any other key dismisses it.

Confirmation removes the credential-leak class (nothing is sent without the user pressing Enter at a visible hint), so detection is relaxed to a broad match (Ubuntu/PAM bare "Password:", "[sudo] password for…", localized prompts) and the per-host toggle is removed — always available when the host has a saved password.

Safety guards:
- don't arm when the hint can't render (no overlay) so Enter isn't silently intercepted;
- swallow Escape/Backspace so the byte never reaches the no-echo prompt;
- clear the pending hint once output moves past the prompt (sudo timeout/failure/returns to shell) so a later Enter can't leak the password to the shell.

Implementation ~140 lines; full suite green; manually verified on a real Linux host.
2026-06-07 12:39:06 +08:00
陈大猫
3be3c14912 fix(terminal): simplify sudo password autofill, scoped to real sudo prompts (#1281)
Replace the command-rewriting scheme (inject sudo -p marker, sanitize the echo, Ctrl-U retype) with passive observation: arm a short window on a sudo command and fill the password when a real sudo prompt appears.

- Fixes the Ubuntu/PAM no-fill, the cursor jump below the prompt, and the typed-vs-autocomplete discrepancy from #1281.
- Detection requires the [sudo] tag, or a whole-line bare "Password:" / "密码:"; prefixed prompts (mysql -p "Enter password:", ssh "x@h's password:", psql "Password for user x:") are rejected so the sudo password can't leak to a child program when sudo's creds are warm.
- Disarms when a non-sudo command follows, so a stale window can't fill a later prompt.

Implementation: 322 -> ~140 lines.
2026-06-07 03:11:25 +08:00
60 changed files with 1128 additions and 743 deletions

View File

@@ -129,6 +129,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
isImmersiveActive={activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}

View File

@@ -234,6 +234,7 @@ export const enAiMessages: Messages = {
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.windowOpacity': 'Window opacity',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',

View File

@@ -264,14 +264,15 @@ 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. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (not the focus-mode terminal list) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !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',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',

View File

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',

View File

@@ -440,9 +440,6 @@ export const enVaultMessages: Messages = {
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.terminal.sudoAutoFill': 'Auto-fill sudo password',
'hostDetails.terminal.sudoAutoFill.desc': 'When this host asks for a sudo password in the terminal, automatically send the saved password.',
'hostDetails.terminal.sudoAutoFill.passwordWarning': 'Auto-fill requires a saved password for this host.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.notes.label': 'Notes',
'hostDetails.notes.placeholder': 'Hardware, project, customer, region, role...',

View File

@@ -234,6 +234,7 @@ export const ruAiMessages: Messages = {
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',

View File

@@ -264,14 +264,15 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не список терминалов Focus) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !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': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',

View File

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
// Connection logs
'logs.table.date': 'Дата',
'logs.table.user': 'Пользователь',

View File

@@ -475,9 +475,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.sftp.sudo.passwordWarning': 'Для режима sudo требуется пароль. Укажите его выше или убедитесь, что сервер разрешает sudo без пароля.',
'hostDetails.sftp.encoding': 'Кодировка имён файлов',
'hostDetails.sftp.encoding.desc': 'Выберите кодировку, используемую для декодирования и отправки имён файлов SFTP.',
'hostDetails.terminal.sudoAutoFill': 'Автозаполнение пароля sudo',
'hostDetails.terminal.sudoAutoFill.desc': 'Когда этот хост запрашивает пароль sudo в терминале, автоматически отправлять сохранённый пароль.',
'hostDetails.terminal.sudoAutoFill.passwordWarning': 'Для автозаполнения нужен сохранённый пароль этого хоста.',
'hostDetails.label.placeholder': 'Метка (например, Production Server)',
'hostDetails.notes.label': 'Заметки',
'hostDetails.notes.placeholder': 'Оборудование, проект, клиент, регион, роль...',

View File

@@ -234,6 +234,7 @@ export const zhCNAiMessages: Messages = {
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '窗口透明度',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',

View File

@@ -248,14 +248,15 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-side-panelSFTP/脚本/主题/AI 侧栏、terminal-sftp-panel、terminal-split-pane、terminal-split-resizer、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(不是 Focus 模式终端列表) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !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': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
// Context menus / common actions
'action.newHost': '新建主机',
'action.newSubfolder': '新建文件夹',

View File

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',

View File

@@ -33,9 +33,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.sftp.encoding': '文件名编码',
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
'hostDetails.terminal.sudoAutoFill': '自动填写 sudo 密码',
'hostDetails.terminal.sudoAutoFill.desc': '这台主机在终端中请求 sudo 密码时,自动发送保存的密码。',
'hostDetails.terminal.sudoAutoFill.passwordWarning': '自动填写需要先为这台主机保存密码。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.notes.label': '备注',
'hostDetails.notes.placeholder': '硬件配置、项目、客户、地域、角色...',

View File

@@ -33,9 +33,14 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
interface UseSettingsIpcSyncParams {
syncAppearanceFromStorage: () => void;
@@ -59,6 +64,7 @@ interface UseSettingsIpcSyncParams {
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -88,6 +94,7 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
@@ -191,6 +198,10 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
@@ -223,6 +234,7 @@ export function useSettingsIpcSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,

View File

@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
export const DEFAULT_WINDOW_OPACITY = 1;
export function clampWindowOpacity(opacity: unknown): number {
const value = Number(opacity);
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
return Math.min(1, Math.max(0.5, value));
}
/** Resolve the current OS color scheme preference. */
export const getSystemPreference = (): 'light' | 'dark' =>

View File

@@ -39,8 +39,10 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import {
clampWindowOpacity,
isValidHslToken,
isValidTheme,
isValidUiFontId,
@@ -79,6 +81,7 @@ interface UseSettingsStorageSyncParams {
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
@@ -110,6 +113,7 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
@@ -125,7 +129,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -134,7 +138,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
@@ -148,7 +152,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -158,7 +162,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -372,6 +376,12 @@ export function useSettingsStorageSync({
setAutoUpdateEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
@@ -400,6 +410,7 @@ export function useSettingsStorageSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,

View File

@@ -4,6 +4,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.

View File

@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
/**
* Infer MIME type from file extension when the browser/Electron doesn't
* provide one (common for .yaml, .sh, .toml, and other code/text files).
*/
const EXTENSION_MIME_TYPES: Record<string, string> = {
// Code & Scripts — all use text/plain for maximum provider compatibility
js: 'text/plain',
mjs: 'text/plain',
cjs: 'text/plain',
jsx: 'text/plain',
ts: 'text/plain',
tsx: 'text/plain',
py: 'text/plain',
rb: 'text/plain',
rs: 'text/plain',
go: 'text/plain',
java: 'text/plain',
c: 'text/plain',
h: 'text/plain',
cpp: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
kt: 'text/plain',
scala: 'text/plain',
php: 'text/plain',
pl: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
fish: 'text/plain',
ps1: 'text/plain',
bat: 'text/plain',
cmd: 'text/plain',
sql: 'text/plain',
r: 'text/plain',
lua: 'text/plain',
dart: 'text/plain',
// Web
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/plain',
sass: 'text/plain',
less: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
// Config / Data
yaml: 'text/plain',
yml: 'text/plain',
json: 'application/json',
jsonc: 'application/json',
jsonl: 'application/jsonl',
xml: 'application/xml',
toml: 'application/toml',
csv: 'text/csv',
tsv: 'text/tab-separated-values',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
env: 'text/plain',
// Docs
md: 'text/markdown',
markdown: 'text/markdown',
txt: 'text/plain',
tex: 'text/x-tex',
rst: 'text/x-rst',
log: 'text/plain',
// Other typed files
pdf: 'application/pdf',
dockerfile: 'text/plain',
};
function getExtension(fileName: string): string {
const dot = fileName.lastIndexOf('.');
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
return fileName.slice(dot + 1).toLowerCase();
}
function inferMediaType(fileName: string, fileType: string): string {
if (fileType) return fileType;
const ext = getExtension(fileName);
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
}
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
const mediaType = inferMediaType(filename, file.type);
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);

View File

@@ -36,6 +36,7 @@ import {
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -83,6 +84,8 @@ import {
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
@@ -278,6 +281,19 @@ export const useSettingsState = () => {
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
if (stored === null) return DEFAULT_WINDOW_OPACITY;
return clampWindowOpacity(stored);
});
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
setWindowOpacityState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev)
: nextValue;
return clampWindowOpacity(candidate);
});
}, []);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
@@ -576,6 +592,7 @@ export const useSettingsState = () => {
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
@@ -608,7 +625,7 @@ export const useSettingsState = () => {
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -617,7 +634,7 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -815,6 +832,7 @@ export const useSettingsState = () => {
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -986,6 +1004,8 @@ export const useSettingsState = () => {
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
rehydrateAllFromStorage,
reapplyCurrentTheme,
workspaceFocusStyle,

View File

@@ -1,7 +1,6 @@
import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { sanitizeCredentialValue } from "../domain/credentials";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
@@ -47,10 +46,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
effectiveFormDistro,
getDistroOptionLabel,
}) => {
const terminalSudoAutoFillPassword = sanitizeCredentialValue(
form.savePassword === false ? undefined : form.password,
);
return (
<>
<HostDetailsSection
@@ -332,21 +327,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
)}
<HostDetailsSettingRow
label={t("hostDetails.terminal.sudoAutoFill")}
hint={t("hostDetails.terminal.sudoAutoFill.desc")}
>
<Switch
checked={form.terminalSudoAutoFill || false}
onCheckedChange={(val) => update("terminalSudoAutoFill", val)}
/>
</HostDetailsSettingRow>
{form.terminalSudoAutoFill && !terminalSudoAutoFillPassword && (
<p className="text-xs text-amber-500">
{t("hostDetails.terminal.sudoAutoFill.passwordWarning")}
</p>
)}
{/* Local key file paths display */}
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">

View File

@@ -325,6 +325,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
/>
)}

View File

@@ -681,6 +681,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
data-section="terminal-sftp-panel"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
onClick={handlePaneFocus}

View File

@@ -107,12 +107,14 @@ interface SyncStatusButtonProps {
onOpenSettings?: () => void;
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
className?: string;
style?: React.CSSProperties;
}
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
onOpenSettings,
onSyncNow,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
@@ -177,9 +179,10 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
style={style}
>
{getButtonIcon()}

View File

@@ -224,6 +224,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -248,9 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
const sudoAutofillEnabledRef = useRef(Boolean(host.terminalSudoAutoFill));
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
sudoAutofillEnabledRef.current = Boolean(host.terminalSudoAutoFill);
sudoAutofillPasswordRef.current = sudoAutofillPassword;
const [chainProgress, setChainProgress] = useState<{
@@ -297,7 +296,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
return data;
}
return prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
return data;
}, []);
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
@@ -643,6 +643,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingAuthRef,
promptLineBreakStateRef,
sudoAutofillRef,
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
updateStatus,
setStatus,
setError,
@@ -690,7 +691,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
sudoAutofillEnabledRef,
sudoAutofillPasswordRef,
});
sessionStartersRef.current = sessionStarters;
@@ -1085,7 +1085,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent);

View File

@@ -24,7 +24,7 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { sanitizeCredentialValue } from '../domain/credentials';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -66,12 +66,6 @@ import {
type TerminalLayerProps,
} from './terminalLayer/TerminalLayerSupport';
const resolveHostSudoAutofillPassword = (host: Host): string | undefined => {
if (!host.terminalSudoAutoFill) return undefined;
if (host.savePassword === false) return undefined;
return sanitizeCredentialValue(host.password) || undefined;
};
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
@@ -615,11 +609,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const session of sessions) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
map.set(session.id, resolveHostSudoAutofillPassword(rawHost));
// Resolve through identity references too (host.identityId), not just
// host.password, so a password stored in a Keychain identity is filled
// (issue #1284) — same resolution SSH login uses.
map.set(session.id, resolveHostAutofillPassword({ host: rawHost, keys, identities }));
}
}
return map;
}, [hostMap, sessions]);
}, [hostMap, sessions, keys, identities]);
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const sessionHost = sessionHostsMapRef.current.get(sessionId);

View File

@@ -12,6 +12,7 @@ import { Button } from './ui/button';
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SyncStatusButton } from './SyncStatusButton';
import { WindowOpacityButton } from './WindowOpacityButton';
import {
ActiveTabAutoScroller,
EditorTopTab,
@@ -49,6 +50,8 @@ interface TopTabsProps {
onOpenQuickSwitcher: () => void;
onToggleTheme: () => void;
onOpenSettings: () => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
@@ -82,6 +85,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onOpenQuickSwitcher,
onToggleTheme,
onOpenSettings,
windowOpacity,
setWindowOpacity,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
@@ -591,14 +596,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Tooltip>
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
{/* Fixed right controls — utility icons + window controls share one row */}
<div
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7"
style={dragRegionStyle}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
@@ -607,13 +615,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
</Tooltip>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<WindowOpacityButton
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
className="h-7 w-7 shrink-0"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<SyncStatusButton
onOpenSettings={onOpenSettings}
onSyncNow={onSyncNow}
className="h-7 w-7 shrink-0"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive && !followAppTerminalTheme}
@@ -623,15 +642,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
</Tooltip>
</div>
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
<div className="self-stretch flex items-center px-2 app-drag" style={dragRegionStyle}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenSettings}
>
@@ -640,11 +656,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
</Tooltip>
{!isMacClient && <WindowControls />}
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0 self-end" />}
</div>
</div>
);
@@ -665,6 +680,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onOpenSettings === next.onOpenSettings &&
prev.windowOpacity === next.windowOpacity &&
prev.setWindowOpacity === next.setWindowOpacity &&
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Droplets } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
const OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
interface WindowOpacityButtonProps {
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
className?: string;
style?: React.CSSProperties;
}
export const WindowOpacityButton: React.FC<WindowOpacityButtonProps> = ({
windowOpacity,
setWindowOpacity,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const percent = Math.round(windowOpacity * 100);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-7 w-7 shrink-0 app-no-drag', className)}
style={style}
aria-label={t('topTabs.windowOpacity')}
>
<Droplets
size={16}
className={percent < 100 ? 'opacity-80' : undefined}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('topTabs.windowOpacity')}</TooltipContent>
</Tooltip>
<PopoverContent
className="w-60 p-3 app-no-drag"
align="end"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-2">
<div className="text-sm font-medium">{t('topTabs.windowOpacity')}</div>
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={5}
value={percent}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="flex-1 accent-primary"
/>
<span className="text-xs text-muted-foreground w-9 text-right tabular-nums">
{percent}%
</span>
</div>
<div className="flex items-center gap-1.5">
{OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
'flex-1 px-2 py-1 rounded-md text-xs font-medium transition-colors border',
windowOpacity === preset.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/50 text-muted-foreground border-border hover:text-foreground',
)}
>
{preset.label}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default WindowOpacityButton;

View File

@@ -32,6 +32,8 @@ export default function SettingsAppearanceTab(props: {
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -58,8 +60,16 @@ export default function SettingsAppearanceTab(props: {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
windowOpacity,
setWindowOpacity,
} = props;
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -172,6 +182,48 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.windowOpacity")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.windowOpacity")}
description={t("settings.appearance.windowOpacity.desc")}
>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={5}
value={Math.round(windowOpacity * 100)}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{Math.round(windowOpacity * 100)}%
</span>
</div>
<div className="flex items-center gap-1.5">
{WINDOW_OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
windowOpacity === preset.value
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:text-foreground",
)}
>
{preset.label}
</button>
))}
</div>
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow

View File

@@ -34,6 +34,8 @@ interface TerminalAutocompleteProps {
inputRef: HandlerRef<(data: string) => void>;
repositionRef: HandlerRef<() => void>;
closeRef: HandlerRef<() => void>;
sudoHintRef: HandlerRef<(active: boolean) => boolean>;
sudoHintText: string;
}
/**
@@ -67,6 +69,8 @@ export function TerminalAutocomplete({
inputRef,
repositionRef,
closeRef,
sudoHintRef,
sudoHintText,
}: TerminalAutocompleteProps) {
const autocomplete = useTerminalAutocomplete({
termRef,
@@ -88,6 +92,13 @@ export function TerminalAutocomplete({
inputRef.current = autocomplete.handleInput;
repositionRef.current = autocomplete.repositionPopup;
closeRef.current = autocomplete.closePopup;
sudoHintRef.current = (active: boolean): boolean => {
if (!active) {
autocomplete.hideSudoHint();
return false;
}
return autocomplete.showSudoHint(sudoHintText);
};
const { state } = autocomplete;
if (!visible || !state.popupVisible || state.suggestions.length === 0) {

View File

@@ -4,7 +4,7 @@ import React from 'react';
type TerminalViewContext = Record<string, any>;
export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -547,6 +547,8 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
inputRef={autocompleteInputRef}
repositionRef={autocompleteRepositionRef}
closeRef={autocompleteCloseRef}
sudoHintRef={sudoHintRef}
sudoHintText={sudoHintText}
/>
{/* OSC-52 clipboard read prompt */}

View File

@@ -50,6 +50,8 @@ function stringCellWidth(s: string): number {
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private hintElement: HTMLSpanElement | null = null;
private hintActive = false;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
@@ -104,6 +106,23 @@ export class GhostTextAddon implements IDisposable {
this.containerElement.appendChild(this.ghostElement);
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
// independently of autocomplete suggestions and never accepted as input.
this.hintElement = document.createElement("span");
this.hintElement.className = "xterm-inline-hint";
Object.assign(this.hintElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.hintElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
@@ -113,6 +132,7 @@ export class GhostTextAddon implements IDisposable {
this.disposables.push(
term.onRender(() => {
if (this.hintActive) this.updateHintPosition();
if (!this.isVisible()) return;
// Fail-safe: if the device echoed input we didn't track (some bastion
// hosts / network OS, #1013), hide rather than draw the ghost over
@@ -137,6 +157,7 @@ export class GhostTextAddon implements IDisposable {
this.lastLeft = -1;
this.lastTop = -1;
if (this.isVisible()) this.updatePosition();
if (this.hintActive) this.updateHintPosition();
}),
);
}
@@ -185,6 +206,40 @@ export class GhostTextAddon implements IDisposable {
this.anchorInputLength = 0;
}
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
* hint). Independent of autocomplete suggestions; never accepted as input. */
showHint(text: string): void {
if (this.disposed || !this.hintElement || !this.term) return;
this.hintActive = true;
this.hintElement.textContent = text;
this.hintElement.style.display = "block";
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
this.updateHintPosition();
}
hideHint(): void {
this.hintActive = false;
if (this.hintElement) {
this.hintElement.style.display = "none";
this.hintElement.textContent = "";
}
}
isHintActive(): boolean {
return this.hintActive;
}
private updateHintPosition(): void {
if (!this.term || !this.hintElement) return;
const dims = getXTermCellDimensions(this.term);
const buf = this.term.buffer.active;
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
this.hintElement.style.lineHeight = `${dims.height}px`;
this.hintElement.style.height = `${dims.height}px`;
}
/**
* Re-align the ghost against a freshly-updated user input synchronously.
* Called from handleInput on every keystroke that mutates the typed
@@ -379,6 +434,7 @@ export class GhostTextAddon implements IDisposable {
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.hintElement = null;
this.term = null;
}
}

View File

@@ -133,6 +133,8 @@ export interface TerminalAutocompleteHandle {
repositionPopup: () => void;
closePopup: () => void;
dispose: () => void;
showSudoHint: (text: string) => boolean;
hideSudoHint: () => void;
}
export { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
@@ -910,6 +912,16 @@ export function useTerminalAutocomplete(
ghostAddonRef.current = null;
}, []);
const showSudoHint = useCallback((text: string): boolean => {
const addon = ghostAddonRef.current;
if (!addon) return false;
addon.showHint(text);
return addon.isHintActive();
}, []);
const hideSudoHint = useCallback(() => {
ghostAddonRef.current?.hideHint();
}, []);
useEffect(() => {
return () => { dispose(); };
}, [dispose]);
@@ -923,5 +935,7 @@ export function useTerminalAutocomplete(
repositionPopup,
closePopup,
dispose,
showSudoHint,
hideSudoHint,
};
}

View File

@@ -5,14 +5,11 @@ import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
const prepareSudoPrompt = (
autofill: { prepareCommand: (command: string) => string | null } | null,
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
): string => {
const prepared = autofill?.prepareCommand("sudo whoami");
assert.ok(prepared);
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
assert.ok(prompt);
return prompt.replace("%p", "alice");
autofill?.armForCommand("sudo whoami");
return "[sudo] password for alice: ";
};
const makeBackend = (
@@ -103,14 +100,15 @@ test("startEt enables sudo autofill with the host saved password", async () => {
const ctx = {
...makeCtx({
password: "saved-secret",
terminalSudoAutoFill: true,
}, [], backend),
sudoAutofillRef,
sudoAutofillPassword: "saved-secret",
onSudoHint: () => true,
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
onData?.(prepareSudoPrompt(sudoAutofillRef.current));
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, ["saved-secret\n"]);
});

View File

@@ -8,14 +8,11 @@ import {
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
const prepareSudoPrompt = (
autofill: { prepareCommand: (command: string) => string | null } | null,
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
): string => {
const prepared = autofill?.prepareCommand("sudo whoami");
assert.ok(prepared);
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
assert.ok(prompt);
return prompt.replace("%p", "alice");
autofill?.armForCommand("sudo whoami");
return "[sudo] password for alice: ";
};
test("startMosh enables sudo autofill with the host saved password", async () => {
@@ -51,7 +48,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
hostname: "target.example.test",
username: "alice",
password: "saved-secret",
terminalSudoAutoFill: true,
},
keys: [],
identities: [],
@@ -60,6 +56,7 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
terminalSettings: {},
terminalBackend,
sudoAutofillPassword: "saved-secret",
onSudoHint: () => true,
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
@@ -88,7 +85,8 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
onData?.(prepareSudoPrompt(sudoAutofillRef.current));
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, ["saved-secret\n"]);
});

View File

@@ -11,15 +11,12 @@ import { pasteTextIntoTerminal } from "./terminalUserPaste";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
const prepareSudoPrompt = (
autofill: { prepareCommand: (command: string) => string | null } | null,
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
command = "sudo whoami",
): string => {
const prepared = autofill?.prepareCommand(command);
assert.ok(prepared);
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
assert.ok(prompt);
return prompt.replace("%p", "alice");
autofill?.armForCommand(command);
return "[sudo] password for alice: ";
};
const createTermStub = () => ({
@@ -31,6 +28,7 @@ const createTermStub = () => ({
});
const createStarterContext = (overrides: Record<string, unknown> = {}) => ({
onSudoHint: () => true,
host: {
id: "host-1",
label: "Target",
@@ -195,14 +193,14 @@ test("startSSH enables sudo autofill only with the host saved password", async (
hostname: "target.example.test",
username: "alice",
password: "saved-secret",
terminalSudoAutoFill: true,
},
terminalBackend,
sudoAutofillPassword: "saved-secret",
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
onData?.(prepareSudoPrompt(ctx.sudoAutofillRef.current));
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
ctx.sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, ["saved-secret\n"]);
});
@@ -238,7 +236,6 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
label: "Target",
hostname: "target.example.test",
username: "alice",
terminalSudoAutoFill: true,
},
pendingAuthRef: {
current: {
@@ -252,7 +249,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
@@ -289,7 +286,6 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
label: "Target",
hostname: "target.example.test",
username: "alice",
terminalSudoAutoFill: true,
},
pendingAuthRef: {
current: {
@@ -300,12 +296,11 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
},
},
terminalBackend,
sudoAutofillEnabledRef: { current: true },
sudoAutofillPasswordRef: { current: undefined },
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
@@ -343,13 +338,12 @@ test("startSSH does not use merged group default passwords for sudo autofill", a
hostname: "target.example.test",
username: "alice",
password: "group-default-secret",
terminalSudoAutoFill: true,
},
terminalBackend,
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
@@ -386,62 +380,18 @@ test("startSSH uses the provided sudo autofill password", async () => {
label: "Target",
hostname: "target.example.test",
username: "alice",
terminalSudoAutoFill: true,
},
terminalBackend,
sudoAutofillPassword: "host-secret",
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
onData?.(prepareSudoPrompt(ctx.sudoAutofillRef.current));
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
ctx.sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, ["host-secret\n"]);
});
test("startSSH leaves sudo autofill disabled when the host switch is off", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (_id: string, data: string) => sent.push(data),
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "saved-secret",
terminalSudoAutoFill: false,
},
terminalBackend,
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
});
test("startSerial captures direct connected banner in terminal log data", async () => {
const capturedLogData: string[] = [];
const writtenData: string[] = [];

View File

@@ -45,10 +45,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const resolveSavedSudoAutofillPassword = (): string | undefined => {
const isSudoAutofillEnabled = ctx.sudoAutofillEnabledRef
? ctx.sudoAutofillEnabledRef.current
: ctx.host.terminalSudoAutoFill;
if (!isSudoAutofillEnabled) return undefined;
if (ctx.sudoAutofillPasswordRef) {
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
}

View File

@@ -110,8 +110,8 @@ export type TerminalSessionStartersContext = {
sessionLog?: SessionLogConfig;
sshDebugLogEnabled?: boolean;
sudoAutofillPassword?: string;
sudoAutofillEnabledRef?: RefObject<boolean>;
sudoAutofillPasswordRef?: RefObject<string | undefined>;
onSudoHint?: (active: boolean) => boolean;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;

View File

@@ -8,9 +8,6 @@ import {
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import { createPromptLineBreakState } from "./promptLineBreak";
const TEST_MARKER = "__NETCATTY_SUDO_test__";
const TEST_PREPARED_SUDO_WHOAMI = `sudo -p '[sudo] password for %p: ${TEST_MARKER}' whoami`;
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
return {
buffer: {
@@ -55,36 +52,37 @@ function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number,
};
}
test("sudo autofill input preparation rewrites submitted sudo commands for the remote side", () => {
test("sudo autofill input preparation arms on a submitted sudo command without altering input", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: () => {},
write: (data) => writes.push(data),
onHint: () => true,
});
assert.equal(
prepareSudoAutofillInput("\r", "sudo whoami", autofill),
`\x15${TEST_PREPARED_SUDO_WHOAMI}\r`,
);
assert.equal(prepareSudoAutofillInput("\r", "sudo whoami", autofill), "\r");
autofill.handleOutput("[sudo] password for alice: ");
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill input preparation rewrites single-line pasted sudo commands", () => {
test("sudo autofill input preparation arms on a single-line pasted sudo command", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: () => {},
write: (data) => writes.push(data),
onHint: () => true,
});
assert.equal(
prepareSudoAutofillInput("sudo whoami\n", null, autofill),
`\x15${TEST_PREPARED_SUDO_WHOAMI}\n`,
);
assert.equal(prepareSudoAutofillInput("sudo whoami\n", null, autofill), "sudo whoami\n");
autofill.handleOutput("[sudo] password for alice: ");
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill input preparation preserves bracketed pasted sudo commands", () => {
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: () => {},
});

View File

@@ -618,6 +618,28 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
}
// Sudo password hint: while a hint is pending, Enter confirms (paste the
// saved password + submit); any other visible key dismisses it so the user
// can type the password manually. Checked before autocomplete so Enter
// pastes the password instead of submitting an empty line.
const sudoAutofill = ctx.sudoAutofillRef?.current;
if (sudoAutofill?.isPromptPending()) {
if (e.key === "Enter") {
e.preventDefault();
sudoAutofill.confirmFill();
return false;
}
if (e.key === "Escape" || e.key === "Backspace") {
e.preventDefault();
sudoAutofill.cancelHint();
return false; // dismiss without forwarding the byte to the no-echo prompt
}
if (e.key.length === 1) {
sudoAutofill.cancelHint();
// fall through: key becomes the first char of the manually typed password
}
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent) {
const consumed = ctx.onAutocompleteKeyEvent(e);
@@ -804,11 +826,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const recordedCommand = recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
handledSubmittedInput = true;
if (!willBroadcastInput) {
dataToWrite = prepareSudoAutofillInput(
data,
recordedCommand,
ctx.sudoAutofillRef?.current,
);
prepareSudoAutofillInput(data, recordedCommand, ctx.sudoAutofillRef?.current);
}
} else if (ctx.statusRef.current === "connected" && !willBroadcastInput) {
const pastedCommand = getSinglePastedCommand(data);
@@ -820,13 +838,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
);
handledSubmittedInput = true;
if (recordedCommand) {
const submittedCommand = `${recordedCommand}${pastedCommand.lineEnding}`;
const preparedInput = prepareSudoAutofillInput(
submittedCommand,
prepareSudoAutofillInput(
`${recordedCommand}${pastedCommand.lineEnding}`,
null,
ctx.sudoAutofillRef?.current,
);
dataToWrite = preparedInput === submittedCommand ? data : preparedInput;
}
}
}

View File

@@ -38,13 +38,6 @@ const createContext = (showLineTimestamps: boolean) => ({
promptLineBreakStateRef: { current: undefined },
});
const extractSudoPromptFromPrepared = (prepared: string | null | undefined): string => {
assert.ok(prepared);
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
assert.ok(prompt);
return prompt.replace("%p", "alice");
};
test("writeSessionData prefixes terminal output lines when enabled", () => {
const { term, writes } = createFakeTerm();
writeSessionData(createContext(true) as never, term, "hello\r\nnext");
@@ -110,9 +103,10 @@ test("attachSessionToTerminal resets timestamp state for a reused terminal", ()
assert.ok(writes[1].endsWith("] \x1b[22;39mfresh"));
});
test("attachSessionToTerminal auto-fills sudo password prompts when configured", () => {
test("attachSessionToTerminal hints for sudo password prompts and fills on confirm", () => {
const { term, writes } = createFakeTerm();
const sent: Array<{ id: string; data: string; automated?: boolean }> = [];
const hints: boolean[] = [];
let onData: ((data: string) => void) | null = null;
const sudoAutofillRef = { current: null };
const ctx = {
@@ -127,6 +121,7 @@ test("attachSessionToTerminal auto-fills sudo password prompts when configured",
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
sudoAutofillRef,
onSudoHint: (active: boolean) => hints.push(active),
terminalBackend: {
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
@@ -145,15 +140,20 @@ test("attachSessionToTerminal auto-fills sudo password prompts when configured",
attachSessionToTerminal(ctx as never, term, "session-1", {
sudoAutofillPassword: "secret",
});
const prepared = sudoAutofillRef.current?.prepareCommand("sudo whoami");
const prompt = extractSudoPromptFromPrepared(prepared);
onData?.(`${prepared ?? ""}\r\n`);
onData?.(prompt);
sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("sudo whoami\r\n");
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, [{ id: "session-1", data: "secret\n", automated: true }]);
// Confirm-to-fill model: detecting the prompt raises a hint but never sends
// the password on its own.
assert.deepEqual(hints, [true]);
assert.deepEqual(sent, []);
assert.equal(writes[0], "sudo whoami\r\n");
assert.equal(writes[1], "[sudo] password for alice: ");
assert.ok(!writes.join("").includes("NETCATTY_SUDO"));
// The password is only written once the user confirms (presses Enter).
sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, [{ id: "session-1", data: "secret\n", automated: true }]);
});
test("attachSessionToTerminal does not auto-fill unarmed sudo-looking output", () => {

View File

@@ -233,6 +233,7 @@ export const attachSessionToTerminal = (
const sudoAutofill = createSudoPasswordAutofill({
password: opts?.sudoAutofillPassword,
write: (data) => ctx.terminalBackend.writeToSession(id, data, { automated: true }),
onHint: (active) => ctx.onSudoHint?.(active) ?? false,
});
if (ctx.sudoAutofillRef) {
ctx.sudoAutofillRef.current = sudoAutofill;

View File

@@ -3,311 +3,245 @@ import assert from "node:assert/strict";
import {
createSudoPasswordAutofill,
getSingleBracketedPasteLine,
isExplicitSudoPrompt,
isSudoPasswordPrompt,
shouldArmSudoPasswordAutofill,
} from "./terminalSudoAutofill";
const TEST_PROMPT = "[sudo] password for alice: ";
const TEST_MARKER = "__NETCATTY_SUDO_test__";
const TEST_MARKED_PROMPT = `[sudo] password for alice: ${TEST_MARKER}`;
// --- isSudoPasswordPrompt: relaxed — any password/密码/口令 line ending in a
// colon. Over-matching is safe now because filling requires explicit confirm. ---
const markedCommand = (commandTail: string) =>
`sudo -p '[sudo] password for %p: ${TEST_MARKER}'${commandTail}`;
test("isSudoPasswordPrompt detects the standard sudo password prompt", () => {
test("isSudoPasswordPrompt detects sudo and PAM prompts", () => {
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: "), true);
assert.equal(isSudoPasswordPrompt("Password: "), true);
assert.equal(isSudoPasswordPrompt("password for alice: "), true);
assert.equal(isSudoPasswordPrompt("[sudo: [sudo] password for alice: ] Password: "), true);
});
test("isSudoPasswordPrompt ignores ordinary output mentioning sudo and password", () => {
test("isSudoPasswordPrompt detects localized prompts", () => {
assert.equal(isSudoPasswordPrompt("[sudo] alice 的密码:"), true);
assert.equal(isSudoPasswordPrompt("密码:"), true);
assert.equal(isSudoPasswordPrompt("请输入密码: "), true);
});
test("isSudoPasswordPrompt matches Kylin-style prompts without trailing colon", () => {
// Kylin Professional: sudo prompt has no [sudo] tag and no trailing colon (#1293)
assert.equal(isSudoPasswordPrompt("密码"), true);
assert.equal(isSudoPasswordPrompt("用户 的密码"), true);
assert.equal(isSudoPasswordPrompt("密码 "), true);
// Exact prompts from issue #1293 screenshots (sudo -s on Kylin V10)
assert.equal(isSudoPasswordPrompt("输入密码"), true);
assert.equal(isSudoPasswordPrompt("Input Password"), true);
});
test("isExplicitSudoPrompt matches Kylin-style prompts", () => {
// Kylin-style [sudo] prompt without trailing colon
assert.equal(isExplicitSudoPrompt("[sudo] 密码"), true);
assert.equal(isExplicitSudoPrompt("[sudo] password for alice"), true);
});
test("handleOutput hints on Kylin screenshot sudo prompts when armed", () => {
const { autofill, hints, writes } = make();
autofill.armForCommand("sudo -s");
autofill.handleOutput("输入密码");
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), true);
const english = make();
english.autofill.armForCommand("sudo -s");
english.autofill.handleOutput("Input Password");
assert.deepEqual(english.hints, [true]);
assert.deepEqual(english.writes, []);
});
test("isSudoPasswordPrompt detects color-wrapped prompts", () => {
assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true);
});
test("isSudoPasswordPrompt ignores ordinary output", () => {
assert.equal(isSudoPasswordPrompt("try sudo if the password is required\n"), false);
assert.equal(isSudoPasswordPrompt("password for alice: "), false);
assert.equal(isSudoPasswordPrompt("the password was changed\n"), false);
assert.equal(isSudoPasswordPrompt("sudo: command not found\n"), false);
});
test("isSudoPasswordPrompt requires the expected prompt marker when provided", () => {
assert.equal(isSudoPasswordPrompt(TEST_MARKED_PROMPT, TEST_MARKER), true);
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: ", TEST_MARKER), false);
test("isSudoPasswordPrompt refuses concealed prompt text", () => {
assert.equal(isSudoPasswordPrompt("\x1b[8m[sudo] password for alice: \x1b[0m"), false);
});
test("sudo autofill handles marked prompts split across chunks", () => {
// --- arm + hint (confirm-to-fill) ---
const make = (password = "secret") => {
const writes: string[] = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
now: () => 1_000,
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo apt update"), markedCommand(" apt update"));
assert.equal(autofill.handleOutput("[sudo] password for alice: "), "[sudo] password for alice: ");
assert.equal(autofill.handleOutput(TEST_MARKER), "");
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill ignores sudo-looking output until a sudo command is submitted", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
write: (data) => writes.push(data),
});
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(writes, []);
});
test("sudo autofill sends the password once for a submitted sudo command", () => {
const writes: string[] = [];
let now = 1_000;
const autofill = createSudoPasswordAutofill({
password: "secret",
now: () => now,
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo -i"), markedCommand(" -i"));
autofill.handleOutput(TEST_MARKED_PROMPT);
now += 500;
autofill.handleOutput(TEST_MARKED_PROMPT);
now += 5_000;
autofill.handleOutput(TEST_MARKED_PROMPT);
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill allows target command prompt-like options", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo ssh -p 22 host"), markedCommand(" ssh -p 22 host"));
assert.equal(autofill.prepareCommand("sudo useradd -p hash alice"), markedCommand(" useradd -p hash alice"));
assert.deepEqual(writes, []);
});
test("sudo autofill handles sudo short options with attached arguments", () => {
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: () => {},
});
assert.equal(autofill.prepareCommand("sudo -upostgres whoami"), markedCommand(" -upostgres whoami"));
});
test("sudo autofill leaves commands with explicit sudo prompts unchanged", () => {
const autofill = createSudoPasswordAutofill({
password: "secret",
write: () => {},
});
assert.equal(autofill.prepareCommand("sudo -p custom whoami"), null);
assert.equal(autofill.prepareCommand("sudo --prompt=custom whoami"), null);
});
test("sudo autofill extracts single-line bracketed paste content", () => {
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
});
test("sudo autofill ignores expired sudo command arms", () => {
const writes: string[] = [];
let now = 1_000;
const autofill = createSudoPasswordAutofill({
password: "secret",
now: () => now,
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
now += 31_000;
autofill.handleOutput(TEST_MARKED_PROMPT);
assert.deepEqual(writes, []);
});
test("sudo autofill ignores default sudo-looking output after a submitted command", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo ./program"), markedCommand(" ./program"));
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(writes, []);
});
test("sudo autofill ignores prompt-shaped command output after a submitted command", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
password,
write: (d) => writes.push(d),
onHint: (active) => {
hints.push(active);
return true; // hint overlay shown successfully
},
});
return { autofill, writes, hints };
};
test("shows a hint (not a fill) when a sudo prompt appears", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
assert.equal(
autofill.prepareCommand("sudo printf '[sudo] password for alice: '"),
markedCommand(" printf '[sudo] password for alice: '"),
);
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(writes, []);
});
test("sudo autofill stays armed after ordinary output before the password prompt", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
assert.equal(autofill.handleOutput("sudo: this is the first time notice"), "sudo: this is the first time notice");
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill releases prompt-shaped warm sudo output without sending a password", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(
autofill.prepareCommand("sudo printf '[sudo] password for alice: '"),
markedCommand(" printf '[sudo] password for alice: '"),
);
assert.equal(autofill.handleOutput("[sudo] password for alice: "), "[sudo] password for alice: ");
assert.deepEqual(writes, []);
});
test("sudo autofill hides the prepared command and marker from output", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
const prepared = autofill.prepareCommand("sudo whoami");
assert.equal(prepared, markedCommand(" whoami"));
assert.equal(autofill.handleOutput(`${prepared ?? ""}\r\n`), "sudo whoami\r\n");
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill hides split prepared command and marker output", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
const prepared = autofill.prepareCommand("sudo whoami");
assert.equal(prepared, markedCommand(" whoami"));
const preparedText = prepared ?? "";
const preparedSplitIndex = Math.floor(preparedText.length / 2);
assert.equal(autofill.handleOutput(preparedText.slice(0, preparedSplitIndex)), "");
assert.equal(
autofill.handleOutput(`${preparedText.slice(preparedSplitIndex)}\r\n`),
"sudo whoami\r\n",
);
const promptSplitIndex = TEST_MARKED_PROMPT.length - 6;
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT.slice(0, promptSplitIndex)), "");
assert.equal(
autofill.handleOutput(TEST_MARKED_PROMPT.slice(promptSplitIndex)),
autofill.handleOutput("[sudo] password for alice: "),
"[sudo] password for alice: ",
);
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill keeps sanitizing later shell-history echoes", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
const prepared = autofill.prepareCommand("sudo whoami");
assert.equal(prepared, markedCommand(" whoami"));
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
assert.equal(autofill.handleOutput(`${prepared ?? ""}\r\n`), "sudo whoami\r\n");
assert.deepEqual(writes, ["secret\n"]);
});
test("sudo autofill does not hide completed output when sudo timestamp is warm", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
const prepared = autofill.prepareCommand("sudo true");
assert.equal(prepared, markedCommand(" true"));
assert.equal(autofill.handleOutput(`${prepared}\r\n`), "sudo true\r\n");
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), true);
});
test("sudo autofill releases non-prompt output when sudo timestamp is warm", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
const prepared = autofill.prepareCommand("sudo printf ok");
assert.equal(prepared, markedCommand(" printf ok"));
assert.equal(autofill.handleOutput(`${prepared}\r\n`), "sudo printf ok\r\n");
assert.equal(autofill.handleOutput("ok"), "ok");
assert.deepEqual(writes, []);
});
test("sudo autofill ignores hidden control-sequence prompt text", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
createPromptMarker: () => TEST_MARKER,
write: (data) => writes.push(data),
});
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
autofill.handleOutput(`\x1b[8m${TEST_PROMPT}\x1b[0m`);
assert.deepEqual(writes, []);
});
test("sudo autofill does nothing without a saved password", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "",
write: (data) => writes.push(data),
});
test("confirmFill writes the password and clears the hint", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
assert.deepEqual(hints, [true, false]);
assert.equal(autofill.isPromptPending(), false);
});
test("cancelHint clears the hint without filling", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.cancelHint();
assert.deepEqual(writes, []);
assert.deepEqual(hints, [true, false]);
assert.equal(autofill.isPromptPending(), false);
});
test("confirmFill does nothing when no prompt is pending", () => {
const { autofill, writes } = make();
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("does not arm when the hint cannot be shown (overlay unavailable)", () => {
// If onHint reports the hint could not render (e.g. autocomplete disabled, no
// ghost overlay), we must NOT leave a pending arm — otherwise Enter would
// submit the sudo password with no visible confirmation.
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
write: (d) => writes.push(d),
onHint: () => false,
});
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), false);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("a bare Password prompt does not hint until a sudo command is submitted", () => {
const { autofill, hints } = make();
autofill.handleOutput("Password: ");
assert.deepEqual(hints, []);
});
test("an explicit [sudo] prompt hints without a recorded sudo command", () => {
// The [sudo] tag is sudo-specific, so we hint even when arming didn't fire —
// manual typing's recordedCommand is flaky (#1281/#1284), and the hint only
// pastes on explicit Enter, so showing it is safe.
const { autofill, hints } = make();
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, [true]);
assert.equal(autofill.isPromptPending(), true);
});
test("no hint without a saved password", () => {
const { autofill, hints } = make("");
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, []);
});
test("hint fires once across chunked prompt output", () => {
const { autofill, hints } = make();
autofill.armForCommand("sudo apt update");
autofill.handleOutput("[sudo] password ");
autofill.handleOutput("for alice: ");
assert.deepEqual(hints, [true]);
});
test("relaxed detection still only hints, never auto-fills child prompts", () => {
// sudo creds warm -> mysql asks for its own password. We may hint, but we
// must not send anything without an explicit confirm.
const { autofill, hints, writes } = make();
autofill.armForCommand("sudo mysql -p");
autofill.handleOutput("Enter password: ");
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
});
test("a later non-sudo command disarms the pending hint", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo -n true");
autofill.handleOutput("Password: ");
assert.deepEqual(hints, [true]);
autofill.armForCommand("mysql -p"); // non-sudo command clears the arm
assert.deepEqual(hints, [true, false]);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("clears a pending hint when output moves past the prompt", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
// user never pressed Enter; sudo times out and returns to the shell
autofill.handleOutput("\r\nsudo: timed out reading password\r\nalice@host:~$ ");
assert.equal(autofill.isPromptPending(), false);
assert.deepEqual(hints, [true, false]); // hint was hidden
autofill.confirmFill();
assert.deepEqual(writes, []); // a later Enter no longer sends the password
});
test("keeps the hint pending when sudo re-prompts after a wrong password", () => {
const { autofill, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.handleOutput("\r\nSorry, try again.\r\n[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
assert.deepEqual(hints, [true]);
});
test("an expired arm shows no hint for a bare prompt", () => {
const writes: string[] = [];
const hints: boolean[] = [];
let now = 1_000;
const autofill = createSudoPasswordAutofill({
password: "secret",
now: () => now,
write: (d) => writes.push(d),
onHint: (a) => hints.push(a),
});
autofill.armForCommand("sudo whoami");
now += 31_000;
autofill.handleOutput("Password: ");
assert.deepEqual(hints, []);
});
test("handleOutput passes data through unchanged", () => {
const { autofill } = make();
autofill.armForCommand("sudo whoami");
assert.equal(
autofill.handleOutput("Reading package lists...\r\n"),
"Reading package lists...\r\n",
);
});
test("getSingleBracketedPasteLine extracts single-line bracketed paste content", () => {
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
});
test("shouldArmSudoPasswordAutofill only arms direct sudo commands", () => {

View File

@@ -7,69 +7,55 @@ const OSC_PATTERN = new RegExp(
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
"g",
);
// SGR conceal (parameter 8) hides the text it wraps. Refuse to treat concealed
// output as a real prompt so a remote can't disguise a fake prompt and trick the
// user into revealing the password.
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
// A line that mentions password/密码/口令 and optionally ends in a colon.
// Intentionally broad: filling requires the user to confirm (press Enter), so
// over-matching only shows a dismissable hint and never leaks a password to a
// child program. The colon is optional because Kylin's sudo prompt doesn't
// use one (#1293).
const SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])\s*(?:\[[^\]\r\n]*sudo[^\]\r\n]*\]\s*)?(?=.*\bsudo\b)(?:.*\bpassword\b(?:\s+for\s+[^:\r\n]+)?|.*密码(?:\s*[::前为给]\s*[^:\r\n]*)?)\s*[:]\s*$/i;
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
// An explicit sudo prompt carries the sudo-specific "[sudo]" tag. No other tool
// prompts this way, so we hint on it WITHOUT requiring an arm — keeping the hint
// reliable even when command recording (arming) didn't fire for a manually
// typed command (#1284; manual typing's recordedCommand is flaky).
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
// Colon is optional for Kylin (#1293).
const EXPLICIT_SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
const SUDO_COMMAND_HEAD_PATTERN = /^(\s*(?:builtin\s+|command\s+)?sudo)(?=\s|$)(.*)$/;
const SUDO_SHORT_OPTIONS_WITH_ARGUMENT = new Set(["C", "D", "g", "h", "p", "T", "t", "U", "u"]);
const SUDO_LONG_OPTIONS_WITH_ARGUMENT = new Set([
"chdir",
"close-from",
"command-timeout",
"group",
"host",
"prompt",
"role",
"type",
"user",
]);
export const stripTerminalControlSequences = (data: string): string =>
data.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "");
export const hasTerminalControlSequences = (data: string): boolean =>
data.includes("\x1b") || data.includes("\x07");
export const isSudoPasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const isSudoPasswordPrompt = (
data: string,
expectedPrompt?: string,
): boolean => {
if (expectedPrompt) {
if (!data.includes(expectedPrompt)) return false;
return isSudoPasswordPrompt(data.replaceAll(expectedPrompt, ""));
}
if (hasTerminalControlSequences(data)) return false;
const text = stripTerminalControlSequences(data);
return SUDO_PROMPT_PATTERN.test(text);
export const isExplicitSudoPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return EXPLICIT_SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const shouldArmSudoPasswordAutofill = (command: string): boolean =>
SUDO_COMMAND_PATTERN.test(command);
export type SudoPasswordAutofill = {
prepareCommand: (command: string) => string | null;
armForCommand: (command: string) => void;
handleOutput: (data: string) => string;
confirmFill: () => void;
cancelHint: () => void;
isPromptPending: () => boolean;
updatePassword: (password?: string) => void;
};
export const prepareSudoAutofillInput = (
data: string,
recordedCommand: string | null,
sudoAutofill: SudoPasswordAutofill | null | undefined,
): string => {
if (data !== "\r" && data !== "\n") {
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data;
}
const pastedCommand = getSinglePastedCommand(data);
if (!pastedCommand) return data;
const preparedCommand = sudoAutofill?.prepareCommand(pastedCommand.command);
if (!preparedCommand) return data;
return `\x15${preparedCommand}${pastedCommand.lineEnding}`;
}
if (recordedCommand) {
const preparedCommand = sudoAutofill?.prepareCommand(recordedCommand);
if (preparedCommand) return `\x15${preparedCommand}${data}`;
const unwrapBracketedPaste = (data: string): string => {
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
}
return data;
};
@@ -94,226 +80,122 @@ export const getSingleBracketedPasteLine = (data: string): string | null => {
return text;
};
const unwrapBracketedPaste = (data: string): string => {
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
// Arm the autofill when a sudo command is submitted. The user's input is sent to
// the remote verbatim — we never rewrite it — so the terminal echo and cursor
// stay correct.
export const prepareSudoAutofillInput = (
data: string,
recordedCommand: string | null,
sudoAutofill: SudoPasswordAutofill | null | undefined,
): string => {
if (!sudoAutofill) return data;
if (data === "\r" || data === "\n") {
if (recordedCommand) sudoAutofill.armForCommand(recordedCommand);
return data;
}
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data;
}
const pastedCommand = getSinglePastedCommand(data);
if (pastedCommand) sudoAutofill.armForCommand(pastedCommand.command);
return data;
};
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", "'\\''")}'`;
const createDefaultSudoPromptMarker = (): string =>
`__NETCATTY_SUDO_${Math.random().toString(36).slice(2, 14)}__`;
const splitShellWords = (input: string): string[] => {
const words: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
let escaped = false;
for (const char of input) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\" && quote !== "'") {
escaped = true;
continue;
}
if ((char === "'" || char === "\"") && !quote) {
quote = char;
continue;
}
if (quote === char) {
quote = null;
continue;
}
if (!quote && /\s/.test(char)) {
if (current) {
words.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) words.push(current);
return words;
};
const hasSudoPromptOption = (command: string): boolean => {
const match = command.match(SUDO_COMMAND_HEAD_PATTERN);
if (!match) return false;
const tokens = splitShellWords(match[2]);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === "--") return false;
if (!token.startsWith("-") || token === "-") return false;
if (token === "--prompt" || token.startsWith("--prompt=")) return true;
if (token.startsWith("--")) {
const optionName = token.slice(2).split("=")[0];
if (SUDO_LONG_OPTIONS_WITH_ARGUMENT.has(optionName) && !token.includes("=")) {
index += 1;
}
continue;
}
const shortOptions = token.slice(1);
for (let optionIndex = 0; optionIndex < shortOptions.length; optionIndex += 1) {
const option = shortOptions[optionIndex];
if (option === "p") return true;
if (SUDO_SHORT_OPTIONS_WITH_ARGUMENT.has(option)) {
if (optionIndex === shortOptions.length - 1 && index + 1 < tokens.length) {
index += 1;
}
break;
}
}
}
return false;
};
const buildSudoCommandWithPrompt = (
command: string,
prompt: string,
): string | null => {
if (hasSudoPromptOption(command)) return null;
const match = command.match(SUDO_COMMAND_HEAD_PATTERN);
if (!match) return null;
return `${match[1]} -p ${shellQuote(prompt)}${match[2]}`;
};
const isPotentialPreparedCommandPrefix = (
data: string,
preparedCommand?: string | null,
): boolean =>
Boolean(preparedCommand && data && (
preparedCommand.startsWith(data) || data.startsWith(preparedCommand)
));
const hasExpectedMarkerPrefix = (data: string, marker?: string | null): boolean => {
if (!data || !marker) return false;
const maxPrefixLength = Math.min(data.length, marker.length - 1);
for (let length = maxPrefixLength; length > 0; length -= 1) {
if (data.endsWith(marker.slice(0, length))) return true;
}
return false;
};
// Confirm-to-fill model: when a sudo command is armed and a password prompt is
// seen, we DON'T send the password — we raise a hint (onHint(true)) so the UI can
// offer "press Enter to paste". The password is only written when the user
// confirms via confirmFill(). This makes over-broad detection safe: a misfire
// just shows a dismissable hint instead of leaking the password.
export const createSudoPasswordAutofill = (_options: {
password?: string;
write: (data: string) => void;
/** Show/hide the inline hint. Returns whether the hint actually rendered;
* false (e.g. no overlay available) means we must not arm a confirmation. */
onHint?: (active: boolean) => boolean;
now?: () => number;
createPromptMarker?: () => string;
}): SudoPasswordAutofill => {
const options = {
now: () => Date.now(),
createPromptMarker: createDefaultSudoPromptMarker,
onHint: () => false,
..._options,
};
let password = options.password ?? "";
const cooldownMs = 3_000;
const armWindowMs = 30_000;
const armWindowMs = 10_000;
let tail = "";
let expectedPromptMarker: string | null = null;
let originalCommand: string | null = null;
let preparedCommand: string | null = null;
let outputSanitizePending = "";
let lastSentAt = Number.NEGATIVE_INFINITY;
let armedUntil = Number.NEGATIVE_INFINITY;
let pending = false;
const disarm = () => {
armedUntil = Number.NEGATIVE_INFINITY;
tail = "";
outputSanitizePending = "";
};
const applyOutputSanitizers = (data: string): string => {
let nextData = data;
if (preparedCommand && originalCommand) {
nextData = nextData.replaceAll(preparedCommand, originalCommand);
if (pending) {
pending = false;
options.onHint(false);
}
if (expectedPromptMarker) {
nextData = nextData.replaceAll(expectedPromptMarker, "");
}
return nextData;
};
const sanitizeOutput = (data: string, opts: { final?: boolean } = {}): string => {
if (!preparedCommand && !expectedPromptMarker && !outputSanitizePending) return data;
outputSanitizePending += data;
const lastLineBreakIndex = Math.max(
outputSanitizePending.lastIndexOf("\n"),
outputSanitizePending.lastIndexOf("\r"),
);
if (!opts.final && lastLineBreakIndex < 0) {
return "";
}
const readyLength = opts.final
? outputSanitizePending.length
: lastLineBreakIndex + 1;
const readyData = outputSanitizePending.slice(0, readyLength);
outputSanitizePending = outputSanitizePending.slice(readyLength);
return applyOutputSanitizers(readyData);
};
return {
prepareCommand: (command: string) => {
if (!password || !shouldArmSudoPasswordAutofill(command)) return null;
const promptMarker = options.createPromptMarker();
const prompt = `[sudo] password for %p: ${promptMarker}`;
const nextPreparedCommand = buildSudoCommandWithPrompt(command, prompt);
if (!nextPreparedCommand) return null;
expectedPromptMarker = promptMarker;
originalCommand = command;
preparedCommand = nextPreparedCommand;
armForCommand: (command: string) => {
// Clear any prior arm/hint first: a non-sudo command must not leave a
// stale hint that a later prompt could satisfy.
disarm();
if (!password || !shouldArmSudoPasswordAutofill(command)) return;
armedUntil = options.now() + armWindowMs;
tail = "";
return nextPreparedCommand;
},
handleOutput: (data: string) => {
const sanitizedData = sanitizeOutput(data);
if (!password || armedUntil === Number.NEGATIVE_INFINITY) {
if (
!sanitizedData &&
outputSanitizePending &&
!isPotentialPreparedCommandPrefix(outputSanitizePending, preparedCommand) &&
!hasExpectedMarkerPrefix(outputSanitizePending, expectedPromptMarker)
) {
return sanitizeOutput("", { final: true });
}
return sanitizedData;
}
if (!password) return data;
tail = `${tail}${data}`.slice(-1024);
const currentTime = options.now();
if (currentTime > armedUntil) {
const finalSanitizedData = sanitizedData + sanitizeOutput("", { final: true });
disarm();
return finalSanitizedData;
// Fast path for bulk output: a prompt line ends in a colon, so a chunk
// with no colon can't be completing one. Skip the regex work unless a hint
// is pending (then we must keep watching for the prompt moving on).
// Also check for password keywords because Kylin's sudo prompt doesn't
// end with a colon (#1293).
if (
!pending &&
!data.includes(":") &&
!data.includes("") &&
!/(?:\bpassword\b|密码|口令)/i.test(data)
) {
return data;
}
if (currentTime - lastSentAt < cooldownMs) return sanitizedData;
const lastLine = tail.split(/[\r\n]/).pop() ?? tail;
if (!expectedPromptMarker || !isSudoPasswordPrompt(lastLine, expectedPromptMarker)) {
if (
lastLine &&
!isPotentialPreparedCommandPrefix(lastLine, preparedCommand) &&
!hasExpectedMarkerPrefix(lastLine, expectedPromptMarker)
) {
return sanitizedData + sanitizeOutput("", { final: true });
}
return sanitizedData;
const armActive =
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
// Explicit "[sudo] …" prompts are sudo-specific → hint regardless of arm,
// so it's reliable even when arming didn't fire (#1284). Bare "Password:"
// only hints inside the arm window, to avoid noise on unrelated prompts
// (ssh, mysql, …).
const isPrompt =
isExplicitSudoPrompt(lastLine) || (armActive && isSudoPasswordPrompt(lastLine));
if (pending) {
// The prompt moved on: a new line arrived and the latest line is no
// longer a password prompt (sudo timed out / failed / returned to the
// shell). Clear the pending hint — otherwise a later Enter would send
// the password to whatever is now reading input.
if (!isPrompt && /[\r\n]/.test(data)) disarm();
return data;
}
options.write(`${password}\n`);
lastSentAt = currentTime;
const finalSanitizedData = sanitizedData + sanitizeOutput("", { final: true });
disarm();
return finalSanitizedData;
if (isPrompt) {
// Only mark pending if the hint actually rendered. If the overlay is
// unavailable (e.g. autocomplete disabled), don't intercept Enter — the
// user would have no visible cue and could leak the password.
if (options.onHint(true)) {
pending = true;
}
}
return data;
},
confirmFill: () => {
if (!pending) return;
options.write(`${password}\n`);
disarm();
},
cancelHint: () => {
if (!pending) return;
disarm();
},
isPromptPending: () => pending,
updatePassword: (nextPassword?: string) => {
password = nextPassword ?? "";
if (!password) disarm();

View File

@@ -719,6 +719,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
return (
<div
data-session-id={session.id}
data-section="terminal-split-pane"
data-focused={isFocusedPane ? 'true' : undefined}
className={cn(
"absolute bg-background",
inActiveWorkspace && "workspace-pane",

View File

@@ -31,6 +31,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
"flex-shrink-0 h-full relative z-20",
sidePanelPosition === 'right' && "order-last",
)}
data-section="terminal-side-panel-shell"
data-side-panel-position={sidePanelPosition}
>
{isSidePanelOpenForCurrentTab && (
<div
@@ -46,6 +48,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
"h-full flex flex-col overflow-hidden",
!isSidePanelOpenForCurrentTab && "pointer-events-none",
)}
data-section="terminal-side-panel"
data-side-panel-tab={isSidePanelOpenForCurrentTab ? (activeSidePanelTab ?? undefined) : undefined}
style={{
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
@@ -402,6 +406,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
<div
key={handle.id}
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
data-section="terminal-split-resizer"
data-split-direction={handle.direction}
style={{
left: `${left}px`,
top: `${top}px`,
@@ -431,6 +437,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
}}
>
<div
data-section="terminal-split-resizer-bar"
className={cn(
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"

View File

@@ -223,7 +223,7 @@ export function useTerminalFocusSidebar({
// (instead of a distinct tinted panel sitting next to it).
backgroundColor: termBg,
color: termFg,
borderRight: `1px solid ${separator}`,
['--terminal-workspace-sidebar-border' as string]: `1px solid ${separator}`,
}}
data-section="terminal-workspace-sidebar"
>

View File

@@ -161,30 +161,19 @@ export const WindowControls: React.FC = memo(() => {
close();
};
const controlStyle = { color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
const controlClassName =
'h-7 w-10 flex items-center justify-center rounded-none hover:bg-foreground/10 transition-colors app-no-drag';
return (
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-full w-10 flex items-center justify-center hover:bg-foreground/10 transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
>
<div className="ml-2 flex items-center h-7">
<button type="button" className={controlClassName} style={controlStyle} onClick={handleMinimize}>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-full w-10 flex items-center justify-center hover:bg-foreground/10 transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
>
{isMaximized ? (
<Copy size={14} />
) : (
<Square size={14} />
)}
<button type="button" className={controlClassName} style={controlStyle} onClick={handleMaximize}>
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
</button>
<button
onClick={handleClose}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
>
<button type="button" className={controlClassName} style={controlStyle} onClick={handleClose}>
<X size={16} />
</button>
</div>

View File

@@ -140,7 +140,6 @@ export interface Host {
serialConfig?: SerialConfig;
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
terminalSudoAutoFill?: boolean; // Auto-fill sudo password prompts in terminal output
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
sftpBookmarks?: SftpBookmark[]; // Bookmarked SFTP paths for quick navigation
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)

View File

@@ -1,8 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveBridgeKeyAuth, resolveHostAuth } from "./sshAuth.ts";
import type { Host, SSHKey } from "./models.ts";
import { resolveBridgeKeyAuth, resolveHostAuth, resolveHostAutofillPassword } from "./sshAuth.ts";
import type { Host, Identity, SSHKey } from "./models.ts";
const referenceKey: SSHKey = {
id: "key-1",
@@ -97,3 +97,59 @@ test("resolveHostAuth respects password auth over stale key selections", () => {
assert.equal(resolved.key, undefined);
assert.equal(resolved.keyId, undefined);
});
const autofillBaseHost = {
id: "h1",
label: "Host",
hostname: "h.example.test",
username: "alice",
} as Host;
test("resolveHostAutofillPassword uses the host's own saved password", () => {
assert.equal(
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "direct-secret" }, keys: [] }),
"direct-secret",
);
});
test("resolveHostAutofillPassword resolves a referenced keychain identity's password", () => {
// host stores no password of its own; the credential lives in a Keychain
// identity it references (host.identityId) — the #1284 scenario.
const identity = {
id: "id-1",
label: "alice@prod",
username: "alice",
authMethod: "password",
password: "identity-secret",
created: 1,
} as Identity;
assert.equal(
resolveHostAutofillPassword({
host: { ...autofillBaseHost, password: undefined, identityId: "id-1" },
keys: [],
identities: [identity],
}),
"identity-secret",
);
});
test("resolveHostAutofillPassword returns undefined when the host opts out of saving", () => {
assert.equal(
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "x", savePassword: false }, keys: [] }),
undefined,
);
});
test("resolveHostAutofillPassword returns undefined when no password is available", () => {
assert.equal(
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: undefined }, keys: [] }),
undefined,
);
});
test("resolveHostAutofillPassword ignores undecryptable password placeholders", () => {
assert.equal(
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "enc:v1:djEwAAAA" }, keys: [] }),
undefined,
);
});

View File

@@ -102,6 +102,22 @@ export const resolveHostAuth = (args: {
};
};
/**
* Resolve the password to use for sudo autofill the same way SSH login does
* (through resolveHostAuth), so a password stored in a referenced Keychain
* identity (host.identityId) is found — not just host.password (issue #1284).
* Returns undefined when the host opts out of saving its password, or none is
* available (pure key auth, or an undecryptable placeholder).
*/
export const resolveHostAutofillPassword = (args: {
host: Host;
keys: SSHKey[];
identities?: Identity[];
}): string | undefined => {
if (args.host.savePassword === false) return undefined;
return sanitizeCredentialValue(resolveHostAuth(args).password) || undefined;
};
export const resolveBridgeKeyAuth = (args: {
key?: SSHKey | null;
fallbackIdentityFilePaths?: string[];

View File

@@ -2,10 +2,10 @@ const DEFAULT_TIMEOUT_MS = 60_000;
const TAIL_LIMIT = 2048;
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login\s*[:>]\s*$/i;
const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)\s*[:>]\s*$/i;
const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)\s*[:>]\s*$/i;
const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\])])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i;
const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login(?:\s*[:>])?\s*$/i;
const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)(?:\s*[:>])?\s*$/i;
const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)(?:\s*[:>])?\s*$/i;
const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\]\)])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i;
const COMMAND_PROMPT_PATTERN = /[$#>]\s*$/;
function stripAnsi(text) {

View File

@@ -236,3 +236,47 @@ test("telnet auto-login avoids common non-prompt login text", () => {
assert.deepEqual(writes, []);
});
test("telnet auto-login works with Kylin-style prompts without trailing colon", () => {
// Kylin Professional prompts may lack trailing colon or angle-bracket (#1293)
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("Username");
autoLogin.handleText("\r\nPassword");
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login works with Kylin-style Chinese prompts without trailing colon", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("用户名");
autoLogin.handleText("\r\n密码");
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login works with Kylin V10 Input Password prompt from issue #1293", () => {
// Screenshot: "lybing-pc login: lybing" then "Input Password" (no trailing colon)
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "lybing",
password: "secret",
write: (data) => writes.push(data),
});
autoLogin.handleText("Kylin V10 SP1\r\nlybing-pc login: ");
autoLogin.handleText("Input Password");
assert.deepEqual(writes, ["lybing\r", "secret\r"]);
});

View File

@@ -33,6 +33,9 @@ let lastFocusedMainWindow = null;
let settingsWindow = null;
let currentTheme = "light";
let currentLanguage = "en";
let currentWindowOpacity = 1;
let cachedNativeTheme = null;
let handlersRegistered = false; // Prevent duplicate IPC handler registration
let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
@@ -324,6 +327,30 @@ function forEachMainWindow(callback) {
}
}
function clampWindowOpacity(opacity) {
const value = Number(opacity);
if (!Number.isFinite(value)) return 1;
return Math.min(1, Math.max(0.5, value));
}
function applyWindowOpacityToWindow(win) {
if (!win || win.isDestroyed?.()) return;
try {
win.setOpacity?.(currentWindowOpacity);
} catch {
// ignore
}
}
function applyWindowOpacity(opacity) {
currentWindowOpacity = clampWindowOpacity(opacity);
forEachMainWindow(applyWindowOpacityToWindow);
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
return currentWindowOpacity;
}
function getMainWindowCount() {
return getMainWindowList().length;
}
@@ -809,6 +836,7 @@ const mainWindowApi = createMainWindowApi({
registerMainWindow,
unregisterMainWindow,
getMainWindowCount,
applyWindowOpacityToWindow,
closeSettingsWindow: (...args) => closeSettingsWindow(...args),
hideSettingsWindow: (...args) => hideSettingsWindow(...args),
});
@@ -852,6 +880,7 @@ const settingsWindowApi = createSettingsWindowApi({
createAppWindowOpenHandler,
createExternalOnlyWindowOpenHandler,
getDevRendererBaseUrl,
applyWindowOpacityToWindow,
});
const {
restoreWindowInputFocus,
@@ -874,6 +903,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return;
}
handlersRegistered = true;
cachedNativeTheme = nativeTheme;
ipcMain.handle("netcatty:window:minimize", (event) => {
const win = getWindowForIpcEvent(event);
@@ -952,7 +982,6 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
: theme;
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
// Also update settings window if open
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(themeConfig.background);
}
@@ -969,6 +998,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return true;
});
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
applyWindowOpacity(opacity);
return true;
});
ipcMain.handle("netcatty:setLanguage", (_event, language) => {
currentLanguage = typeof language === "string" && language.length ? language : "en";
rebuildApplicationMenu();
@@ -1165,4 +1199,7 @@ module.exports = {
tryOpenExternalWithFallback,
resolveSettingsWindowBounds,
THEME_COLORS,
clampWindowOpacity,
applyWindowOpacity,
applyWindowOpacityToWindow,
};

View File

@@ -279,6 +279,8 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
applyWindowOpacityToWindow(win);
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
// Production gets a shorter timeout since the splash screen provides visual feedback.

View File

@@ -233,6 +233,8 @@ function createSettingsWindowApi(ctx) {
} catch {
// ignore
}
applyWindowOpacityToWindow(win);
// Hide instead of close so the window can be reused instantly.
// When the app is quitting, allow normal close/destroy.

View File

@@ -3,6 +3,8 @@ const assert = require("node:assert/strict");
const {
buildAppMenu,
clampWindowOpacity,
applyWindowOpacity,
isWindowUsable,
registerMainWindow,
registerWindowHandlers,
@@ -27,6 +29,44 @@ function createWindowStub({ destroyed = false, webContents } = {}) {
};
}
test("clampWindowOpacity keeps values within 0.5 and 1", () => {
assert.equal(clampWindowOpacity(1), 1);
assert.equal(clampWindowOpacity(0.85), 0.85);
assert.equal(clampWindowOpacity(0.3), 0.5);
assert.equal(clampWindowOpacity(1.5), 1);
assert.equal(clampWindowOpacity("bad"), 1);
});
test("applyWindowOpacity clamps and applies to registered main windows", () => {
const opacityCalls = [];
const win = {
isDestroyed() {
return false;
},
setOpacity(value) {
opacityCalls.push(value);
},
webContents: {
id: 901,
isDestroyed() {
return false;
},
},
};
registerMainWindow(win);
try {
assert.equal(applyWindowOpacity(0.85), 0.85);
assert.deepEqual(opacityCalls, [0.85]);
assert.equal(applyWindowOpacity(0.2), 0.5);
assert.deepEqual(opacityCalls, [0.85, 0.5]);
} finally {
unregisterMainWindow(win);
applyWindowOpacity(1);
}
});
test("isWindowUsable returns false when webContents is crashed", () => {
const win = createWindowStub({
webContents: {
@@ -312,6 +352,7 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
setOpacity() {}
async loadURL() {}
close() {}
}
@@ -358,6 +399,7 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
return true;
},
shouldCloseWindowFromInput,
applyWindowOpacityToWindow() {},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
@@ -423,6 +465,7 @@ test("createWindow registers each main window as an independent app window", asy
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
setOpacity() {}
async loadURL() {}
close() {
this._closedHandler?.();
@@ -476,6 +519,7 @@ test("createWindow registers each main window as an independent app window", asy
unregisterMainWindow(win) {
unregistered.push(win);
},
applyWindowOpacityToWindow() {},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
@@ -540,6 +584,7 @@ test("each main window close saves its own state", async () => {
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
setOpacity() {}
async loadURL() {}
close() {}
}
@@ -591,6 +636,7 @@ test("each main window close saves its own state", async () => {
shouldCloseWindowFromInput,
registerMainWindow() {},
unregisterMainWindow() {},
applyWindowOpacityToWindow() {},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
@@ -679,6 +725,26 @@ test("window IPC handlers target the sender owner window", async () => {
assert.equal(titleResult, true);
assert.deepEqual(titles, ["Prod SSH"]);
const opacityCalls = [];
registerMainWindow({
isDestroyed() {
return false;
},
setOpacity(value) {
opacityCalls.push(value);
},
webContents: {
id: 303,
isDestroyed() {
return false;
},
},
});
const opacityResult = await handlers.get("netcatty:setWindowOpacity")(null, 0.7);
assert.equal(opacityResult, true);
assert.deepEqual(opacityCalls, [0.7]);
});
test("resolveSettingsWindowBounds centers settings on the requesting window display", () => {

View File

@@ -303,6 +303,9 @@ function createPreloadApi(ctx) {
setBackgroundColor: async (color) => {
return ipcRenderer.invoke("netcatty:setBackgroundColor", color);
},
setWindowOpacity: async (opacity) => {
return ipcRenderer.invoke("netcatty:setWindowOpacity", opacity);
},
setLanguage: async (language) => {
return ipcRenderer.invoke("netcatty:setLanguage", language);
},

View File

@@ -450,6 +450,11 @@ body {
overflow: visible;
}
/* Focus-mode terminal list sidebar (split view uses terminal-split-pane). */
[data-section="terminal-workspace-sidebar"] {
border-right: var(--terminal-workspace-sidebar-border, none);
}
/* Dim terminal text in unfocused workspace panes (default) */
.workspace-pane:not(:focus-within) .xterm-screen {
opacity: 0.82;

View File

@@ -119,7 +119,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';

View File

@@ -4,6 +4,7 @@ declare global {
interface NetcattyBridge {
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setWindowOpacity?(opacity: number): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;